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

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

4,846 lines 195.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File Integrity Class
4 *
5 * Handles file integrity monitoring and scanning
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_File_Integrity
17 *
18 * Manages file integrity checks against WordPress.org checksums
19 */
20 class Vigilante_File_Integrity {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * Database instance
31 *
32 * @var Vigilante_Database
33 */
34 private $database;
35
36 /**
37 * Activity log instance
38 *
39 * @var Vigilante_Activity_Log
40 */
41 private $activity_log;
42
43 /**
44 * File integrity options
45 *
46 * @var array
47 */
48 private $options;
49
50 /**
51 * WordPress version
52 *
53 * @var string
54 */
55 private $wp_version;
56
57 /**
58 * Ignored files list
59 *
60 * @var array
61 */
62 private $ignored_files;
63
64 /**
65 * Scan start time for timeout control
66 *
67 * @var float
68 */
69 private $scan_start_time = 0;
70
71 /**
72 * Maximum scan time in seconds (default 60s for thorough scanning)
73 *
74 * @var int
75 */
76 private $max_scan_time = 60;
77
78 /**
79 * Option name for critical files baseline hashes.
80 *
81 * @var string
82 */
83 const BASELINE_OPTION = 'vigilante_critical_files_baseline';
84
85 /**
86 * Option that records which version last redacted the stored baseline.
87 *
88 * @since 2.11.2
89 */
90 const BASELINE_REDACTION_OPTION = 'vigilante_baseline_redaction';
91
92 /**
93 * 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 * The migration the sweep marker stands for.
101 *
102 * A literal, not VIGILANTE_VERSION, and the difference is the whole point.
103 * 2.11.3 stored the running version, so every release after it rearmed the
104 * sweep: the first dashboard load on the main site walked the network with
105 * switch_to_blog() to find nothing, because there was nothing left to find,
106 * for ever, at the price of the one walk the marker exists to avoid.
107 *
108 * A plain boolean would fix that too, and would also leave no way to fire a
109 * second network sweep the day another migration needs one. A literal costs
110 * nothing today and keeps that door open. The redaction marker keeps the
111 * running version on purpose: there the point IS to run again when the list
112 * of what has to be redacted grows, and the cost of reopening it is one
113 * option read rather than a walk of the network.
114 *
115 * And it must be a value NO version ever wrote into this option. The first
116 * draft used '2.11.3', which is precisely what 2.11.3 wrote there, as its
117 * own VIGILANTE_VERSION and BEFORE starting the walk: out in the wild that
118 * value means "started, maybe unfinished". Reading it as "finished" left
119 * every network whose 2.11.3 walk was cut short unswept for good, subsites
120 * still holding the database password and the eight keys. Reproduced on the
121 * Multisite install by a third cross review. The price of the new value is
122 * that networks that did finish in 2.11.3 walk once more and find nothing.
123 *
124 * @since 2.11.4
125 */
126 const BASELINE_SWEEP_MIGRATION = 'network-sweep-done';
127
128 /**
129 * Network option with the fingerprint of every block Vigilant itself wrote
130 * into wp-config.php or the root .htaccess.
131 *
132 * The integrity scan leaves Vigilant's own blocks out of the hash, so that
133 * rewriting them is not reported as somebody else's change. Until 2.11.5 it
134 * left out whatever sat between the markers, without looking. From 2.11.5 a
135 * block is left out only if its content is exactly what Vigilant wrote, as
136 * recorded here at write time.
137 *
138 * @since 2.11.5
139 */
140 const OWNED_BLOCKS_OPTION = 'vigilante_owned_blocks';
141
142 /**
143 * Network option marking that the blocks already on disk have been claimed.
144 *
145 * @since 2.11.5
146 */
147 const OWNED_BLOCKS_CLAIM_OPTION = 'vigilante_owned_blocks_claim';
148
149 /**
150 * Value stored when the claim is done. Deliberately not a version number:
151 * that is the lesson of BASELINE_SWEEP_MIGRATION above.
152 *
153 * @since 2.11.5
154 */
155 const OWNED_BLOCKS_CLAIMED = 'claimed';
156
157 /**
158 * What replaces a secret value kept in the baseline.
159 *
160 * Fixed forever: if this string ever changes, every stored baseline
161 * suddenly differs from the freshly redacted file and every site reports a
162 * change to wp-config.php that never happened.
163 *
164 * @since 2.11.2
165 */
166 const REDACTED_MARKER = '[redacted by Vigilant]';
167
168 /**
169 * Constant names whose value is checked even where the file does not name them
170 *
171 * The eight WordPress keys and salts and the database credentials. Until
172 * 2.11.7 this list, plus names that read like a credential, was what got
173 * redacted, and a real wp-config.php collects secrets under any name:
174 * FTP_PASS, SMTP passwords, cloud keys inside serialize( array( ... ) ),
175 * any const. Since 2.11.8 every value is redacted and this list only feeds
176 * the output check of baseline_content().
177 *
178 * @since 2.11.2
179 *
180 * @var string[]
181 */
182 private static $secret_constants = array(
183 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST',
184 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
185 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
186 );
187
188 /**
189 * Core constants whose value stays readable in the baseline copy
190 *
191 * Where the site lives, where its folders are, how much memory it gets:
192 * none of it is a secret and all of it is what a diff of wp-config.php is
193 * read for. Every other value is redacted. A list of what is secret can
194 * never be complete, which is how 2.11.2 to 2.11.7 missed FTP_PASS; a list
195 * of what is not can be short and still be right.
196 *
197 * @since 2.11.8
198 *
199 * @var string[]
200 */
201 private static $readable_constants = array(
202 'ABSPATH', 'WPINC', 'WP_HOME', 'WP_SITEURL', 'WP_CONTENT_DIR', 'WP_CONTENT_URL',
203 'WP_PLUGIN_DIR', 'WP_PLUGIN_URL', 'WPMU_PLUGIN_DIR', 'WPMU_PLUGIN_URL', 'UPLOADS',
204 'WP_LANG_DIR', 'WP_TEMP_DIR', 'WP_DEBUG_LOG', 'WP_MEMORY_LIMIT', 'WP_MAX_MEMORY_LIMIT',
205 'WP_ENVIRONMENT_TYPE', 'WP_DEVELOPMENT_MODE', 'WP_AUTO_UPDATE_CORE', 'FS_METHOD',
206 'DB_CHARSET', 'DB_COLLATE', 'DOMAIN_CURRENT_SITE', 'PATH_CURRENT_SITE', 'NOBLOGREDIRECT',
207 'COOKIE_DOMAIN', 'COOKIEPATH', 'SITECOOKIEPATH', 'ADMIN_COOKIE_PATH', 'PLUGINS_COOKIE_PATH',
208 'WP_DEFAULT_THEME', 'WPLANG',
209 );
210
211 /**
212 * Read the critical files baseline, from where it belongs
213 *
214 * Both watched files, wp-config.php and the root .htaccess, belong to the
215 * whole network: there is one of each per installation, not one per site.
216 * Keeping the baseline in a per-site option meant every site of a network
217 * stored its own copy of the same wp-config.php, so a network of fifty
218 * sites held fifty copies of the same credentials, and a cleanup that ran
219 * on one site left the other forty nine untouched. Reported by @calzbert on
220 * 10 sep 2026 and reproduced on the Multisite install. Since 2.11.3 there
221 * is one baseline per network.
222 *
223 * No is_multisite() branch on purpose, and this is worth reading before
224 * anyone adds one back: the core functions already make the distinction.
225 * Verified in the installed core, wp-includes/option.php, where
226 * get_network_option() falls back to get_option() on a single site and
227 * update_network_option() falls back to update_option( $option, $value,
228 * false ), autoload already off, which is exactly what this needs. Two
229 * branches doing the same thing are two branches that can drift apart,
230 * and one of them did during this very change.
231 *
232 * @since 2.11.3
233 *
234 * @return array
235 */
236 private function read_baseline() {
237 $baseline = get_site_option( self::BASELINE_OPTION, array() );
238
239 return is_array( $baseline ) ? $baseline : array();
240 }
241
242 /**
243 * Store the critical files baseline where read_baseline() looks for it
244 *
245 * @since 2.11.3
246 *
247 * @param array $baseline Baseline to store.
248 * @return bool
249 */
250 private function write_baseline( $baseline ) {
251 return update_site_option( self::BASELINE_OPTION, $baseline );
252 }
253
254 /**
255 * Fingerprint of a block exactly as the integrity scan reads it back
256 *
257 * @since 2.11.5
258 *
259 * @param string $block Block from start marker to end marker, inclusive.
260 * @return string
261 */
262 private static function block_fingerprint( $block ) {
263 return md5( str_replace( array( "\r\n", "\r" ), "\n", (string) $block ) );
264 }
265
266 /**
267 * Record a block Vigilant has just written
268 *
269 * Called by the writers right after a verified write, so the scan can tell
270 * Vigilant's block from anything else carrying the same markers. By default
271 * it replaces the earlier record for that marker: after a write, only the
272 * block just written is Vigilant's.
273 *
274 * @since 2.11.5
275 *
276 * @param string $filename 'wp-config.php' or '.htaccess'.
277 * @param string $marker_start Start marker of the block.
278 * @param string $block Block from start marker to end marker, inclusive.
279 * @param bool $replace Drop earlier records for the same marker first.
280 * @return bool
281 */
282 public static function remember_owned_block( $filename, $marker_start, $block, $replace = true ) {
283 $owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() );
284 $owned = is_array( $owned ) ? $owned : array();
285 $file = ( isset( $owned[ $filename ] ) && is_array( $owned[ $filename ] ) ) ? $owned[ $filename ] : array();
286
287 if ( $replace ) {
288 foreach ( $file as $fingerprint => $marker ) {
289 if ( $marker === $marker_start ) {
290 unset( $file[ $fingerprint ] );
291 }
292 }
293 }
294
295 $file[ self::block_fingerprint( $block ) ] = $marker_start;
296 $owned[ $filename ] = $file;
297
298 return update_site_option( self::OWNED_BLOCKS_OPTION, $owned );
299 }
300
301 /**
302 * Forget the blocks recorded for a marker, once Vigilant has removed them
303 *
304 * @since 2.11.5
305 *
306 * @param string $filename 'wp-config.php' or '.htaccess'.
307 * @param string $marker_start Start marker of the block.
308 * @return bool
309 */
310 public static function forget_owned_blocks( $filename, $marker_start ) {
311 $owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() );
312
313 if ( ! is_array( $owned ) || empty( $owned[ $filename ] ) || ! is_array( $owned[ $filename ] ) ) {
314 return true;
315 }
316
317 $changed = false;
318
319 foreach ( $owned[ $filename ] as $fingerprint => $marker ) {
320 if ( $marker === $marker_start ) {
321 unset( $owned[ $filename ][ $fingerprint ] );
322 $changed = true;
323 }
324 }
325
326 return $changed ? update_site_option( self::OWNED_BLOCKS_OPTION, $owned ) : true;
327 }
328
329 /**
330 * Whether a block is one Vigilant wrote
331 *
332 * @since 2.11.5
333 *
334 * @param string $filename 'wp-config.php' or '.htaccess'.
335 * @param string $block Block from start marker to end marker, inclusive.
336 * @return bool
337 */
338 private static function is_owned_block( $filename, $block ) {
339 $owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() );
340
341 return is_array( $owned )
342 && isset( $owned[ $filename ] )
343 && is_array( $owned[ $filename ] )
344 && isset( $owned[ $filename ][ self::block_fingerprint( $block ) ] );
345 }
346
347 /**
348 * Whether the blocks already on disk have been claimed
349 *
350 * @since 2.11.5
351 *
352 * @return bool
353 */
354 private function owned_blocks_claimed() {
355 return self::OWNED_BLOCKS_CLAIMED === get_site_option( self::OWNED_BLOCKS_CLAIM_OPTION );
356 }
357
358 /**
359 * The baseline copy of a critical file, with no secret in it
360 *
361 * The integrity scan keeps a copy of wp-config.php so it can show which
362 * lines changed. Until 2.11.1 that copy was the file itself minus the
363 * plugin's own blocks, so the options table held the database password and
364 * the eight authentication keys and salts, and anybody who later read the
365 * database or a backup of it got them without ever touching the
366 * filesystem. Reported by the automated security review of wp.org on 9 sep
367 * 2026 and fixed in 2.11.2.
368 *
369 * The hash is still taken over the whole file, so a change to a secret is
370 * still detected; what changes is that the diff cannot show it, which is
371 * the right trade.
372 *
373 * @since 2.11.2
374 *
375 * @param string $filename Critical file name.
376 * @param string $normalized Normalized content.
377 * @return string Content safe to store, or '' when it cannot be made safe.
378 */
379 private function baseline_content( $filename, $normalized ) {
380 if ( '.htaccess' === $filename ) {
381 return $this->redact_server_secrets( $normalized );
382 }
383
384 if ( 'wp-config.php' !== $filename ) {
385 return $normalized;
386 }
387
388 $redacted = $this->redact_secrets( $normalized );
389
390 /*
391 * Belt and braces, and this is the part that matters: the redaction
392 * above is the thing most likely to miss a shape nobody thought of,
393 * and the cost of missing one is a secret in the database. So the
394 * result is checked against the values actually in force, and if any
395 * of them survived, nothing is stored at all. The scan then reports the
396 * change without a line diff, which the interface already handles,
397 * instead of leaking.
398 *
399 * Until 2.11.7 the check covered the twelve constants of WordPress and
400 * nothing else, so a value the regular expression missed went straight
401 * through it. It now covers every constant the file names and every
402 * environment variable it reads.
403 *
404 * It runs against the copy that is actually stored. A value in force
405 * that sits inside the value of a readable constant, such as a Redis
406 * prefix equal to the domain inside WP_HOME, is not a secret left
407 * behind, so that one alone is not looked for; without that, every such
408 * site would lose its diff. The first version of this checked a
409 * stricter copy instead, and a secret inside a kept include path went
410 * straight past it (cross review of 2.11.8).
411 *
412 * Only values of eight characters or more are checked: DB_NAME is
413 * often something like "local" or "wp", and looking for that inside a
414 * PHP file matches by accident every time.
415 */
416 if ( '' === $redacted ) {
417 return '';
418 }
419
420 $shown = $this->readable_values_in_force();
421
422 foreach ( $this->values_in_force( $normalized ) as $value ) {
423 foreach ( $shown as $readable ) {
424 if ( false !== strpos( $readable, $value ) ) {
425 continue 2;
426 }
427 }
428
429 if ( false !== strpos( $redacted, $value ) ) {
430 return '';
431 }
432 }
433
434 return $redacted;
435 }
436
437 /**
438 * Replace every value in wp-config.php with a marker
439 *
440 * Reads the file as PHP tokens and replaces every string in it: quoted,
441 * with variables inside, heredoc and nowdoc. What stays is what names a
442 * thing rather than holding it: the name passed to define(), defined(),
443 * constant() and getenv(), the name in putenv( 'NAME=value' ), array keys,
444 * an index such as $_ENV['NAME'], and strings of a single character that
445 * are not the value of a define(). Also the value of the constants in
446 * $readable_constants, the table prefix and a path passed to require or
447 * include, which are not secrets and are what a diff of this file is read
448 * for. A path is kept only while it looks like one, and only up to where
449 * its expression ends.
450 *
451 * Until 2.11.7 this was a regular expression over define() with a list of
452 * names, and it missed FTP_PASS, SMTP passwords, cloud keys inside
453 * serialize( array( ... ) ), every const and every value read with a
454 * fallback. Measured while preparing 2.11.8: 10 of 15 real shapes stored
455 * their secret.
456 *
457 * The marker always goes in single quotes, whatever the original used, so
458 * a copy redacted by an earlier version and the same file redacted today
459 * read the same line for line.
460 *
461 * @since 2.11.2
462 * @since 2.11.8 Reads tokens and redacts every value.
463 *
464 * @param string $content Normalized wp-config.php content.
465 * @return string Redacted content, or '' when it cannot be read as tokens.
466 */
467 private function redact_secrets( $content ) {
468 if ( ! function_exists( 'token_get_all' ) ) {
469 return '';
470 }
471
472 $marker = "'" . self::REDACTED_MARKER . "'";
473 $tokens = self::merged_tokens( token_get_all( (string) $content ) );
474 $count = count( $tokens );
475 $names = defined( 'T_NAME_FULLY_QUALIFIED' ) ? array( T_STRING, T_NAME_FULLY_QUALIFIED ) : array( T_STRING );
476 $includes = array( T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE );
477 $out = '';
478 $depth = 0;
479 $keep_until = -1;
480 $define_at = -1;
481 $in_include = false;
482 $include_depth = 0;
483 $include_ends = array( T_CLOSE_TAG, T_BOOLEAN_OR, T_BOOLEAN_AND, T_LOGICAL_OR, T_LOGICAL_AND, T_COALESCE );
484
485 for ( $i = 0; $i < $count; $i++ ) {
486 list( $type, $text, $plain ) = $tokens[ $i ];
487
488 if ( '(' === $type ) {
489 $depth++;
490 } elseif ( ')' === $type ) {
491 // The closing parenthesis of a readable define(), or of any define().
492 if ( $depth === $keep_until ) {
493 $keep_until = -1;
494 }
495 if ( $depth === $define_at ) {
496 $define_at = -1;
497 }
498 $depth--;
499
500 // A parenthesis that closes around the include ends its path.
501 if ( $in_include && $depth < $include_depth ) {
502 $in_include = false;
503 }
504 } elseif ( in_array( $type, $includes, true ) ) {
505 $in_include = true;
506 $include_depth = $depth;
507 } elseif ( $in_include && ( in_array( $type, array( ';', '{', '}', '?', ':', ',' ), true ) || in_array( $type, $include_ends, true ) ) ) {
508 /*
509 * The path of an include ends where its expression does. The
510 * first version of this only ended it at ';', so the value in
511 * `( include 'db.php' ) || define( 'FTP_PASS', '...' )`, in a
512 * ternary after require, or after a closing tag, was kept.
513 * Found by the cross review of 2.11.8.
514 */
515 $in_include = false;
516 }
517
518 if ( T_COMMENT === $type || T_DOC_COMMENT === $type ) {
519 $out .= $this->redact_comment( $text );
520 continue;
521 }
522
523 if ( T_INLINE_HTML === $type ) {
524 $out .= ( '' === trim( $text ) ) ? $text : $marker;
525 continue;
526 }
527
528 if ( 'string' !== $type ) {
529 $out .= $text;
530 continue;
531 }
532
533 $prev = self::significant_token( $tokens, $i, -1 );
534 $next = self::significant_token( $tokens, $i, 1 );
535 $ptype = ( null === $prev ) ? null : $tokens[ $prev ][0];
536 $ntype = ( null === $next ) ? null : $tokens[ $next ][0];
537 $call = ( '(' === $ptype ) ? self::significant_token( $tokens, $prev, -1 ) : null;
538 $inner = $plain ? substr( $text, 1, -1 ) : null;
539
540 if ( $plain && null !== $call && in_array( $tokens[ $call ][0], $names, true ) ) {
541 $function = strtolower( ltrim( $tokens[ $call ][1], '\\' ) );
542
543 if ( in_array( $function, array( 'define', 'defined', 'constant', 'getenv' ), true ) ) {
544 if ( 'define' === $function ) {
545 $define_at = $depth;
546
547 if ( in_array( $inner, self::$readable_constants, true ) ) {
548 $keep_until = $depth;
549 }
550 }
551 $out .= $text;
552 continue;
553 }
554
555 if ( 'putenv' === $function && false !== strpos( $inner, '=' ) ) {
556 $out .= "'" . substr( $inner, 0, strpos( $inner, '=' ) + 1 ) . self::REDACTED_MARKER . "'";
557 continue;
558 }
559 }
560
561 // The token before an opening bracket or an assignment, when there is one.
562 $before = ( '[' === $ptype || '=' === $ptype ) ? self::significant_token( $tokens, $prev, -1 ) : null;
563 $btoken = ( null === $before ) ? array( null, null ) : $tokens[ $before ];
564
565 /*
566 * The value of a define() is redacted whatever its length, as it was
567 * up to 2.11.7, so an empty password reads the same in a copy stored
568 * then as in today's; the first version of this kept strings of one
569 * character there and a file awaiting review showed credential lines
570 * nobody had touched (cross review of 2.11.8).
571 */
572 $is_define_value = ( -1 !== $define_at && $depth === $define_at && ',' === $ptype );
573 $is_path = $in_include && $plain
574 && preg_match( '#^[A-Za-z0-9_./\-]+$#', (string) $inner )
575 && ( false !== strpos( (string) $inner, '/' ) || '.php' === substr( (string) $inner, -4 ) );
576
577 $keep = ( $plain && strlen( $inner ) <= 1 && ! $is_define_value )
578 || T_DOUBLE_ARROW === $ntype
579 || ( '[' === $ptype && ']' === $ntype && in_array( $btoken[0], array( T_VARIABLE, T_STRING, ']', ')', '}' ), true ) )
580 || $keep_until >= 0
581 || $is_path
582 || ( '=' === $ptype && ';' === $ntype && T_VARIABLE === $btoken[0] && '$table_prefix' === $btoken[1] );
583
584 $out .= $keep ? $text : $marker;
585 }
586
587 return $out;
588 }
589
590 /**
591 * PHP tokens with every string folded into a single token
592 *
593 * The tokenizer splits a string with variables inside, a heredoc and a
594 * backtick command into several tokens. For the redaction each of them is
595 * one value, so they come back as a single token of type 'string'. The
596 * third field says whether it is a plain quoted literal.
597 *
598 * @since 2.11.8
599 *
600 * @param array $raw Output of token_get_all().
601 * @return array List of array( type, text, plain ).
602 */
603 private static function merged_tokens( $raw ) {
604 $tokens = array();
605 $count = count( $raw );
606
607 for ( $i = 0; $i < $count; $i++ ) {
608 $token = $raw[ $i ];
609
610 if ( '"' === $token || '`' === $token ) {
611 $text = $token;
612 for ( $i++; $i < $count; $i++ ) {
613 $text .= is_array( $raw[ $i ] ) ? $raw[ $i ][1] : $raw[ $i ];
614 if ( $raw[ $i ] === $token ) {
615 break;
616 }
617 }
618 $tokens[] = array( 'string', $text, false );
619 continue;
620 }
621
622 if ( is_array( $token ) && T_START_HEREDOC === $token[0] ) {
623 $text = $token[1];
624 for ( $i++; $i < $count; $i++ ) {
625 $text .= is_array( $raw[ $i ] ) ? $raw[ $i ][1] : $raw[ $i ];
626 if ( is_array( $raw[ $i ] ) && T_END_HEREDOC === $raw[ $i ][0] ) {
627 break;
628 }
629 }
630 $tokens[] = array( 'string', $text, false );
631 continue;
632 }
633
634 // An unterminated string comes back as T_ENCAPSED_AND_WHITESPACE
635 // on its own, and it is a value like any other.
636 if ( is_array( $token ) && ( T_CONSTANT_ENCAPSED_STRING === $token[0] || T_ENCAPSED_AND_WHITESPACE === $token[0] ) ) {
637 $tokens[] = array( 'string', $token[1], T_CONSTANT_ENCAPSED_STRING === $token[0] );
638 continue;
639 }
640
641 $tokens[] = is_array( $token ) ? array( $token[0], $token[1], false ) : array( $token, $token, false );
642 }
643
644 return $tokens;
645 }
646
647 /**
648 * Index of the nearest token that is not whitespace or a comment
649 *
650 * @since 2.11.8
651 *
652 * @param array $tokens Output of merged_tokens().
653 * @param int $from Index to start from, not included.
654 * @param int $step -1 to look back, 1 to look ahead.
655 * @return int|null
656 */
657 private static function significant_token( $tokens, $from, $step ) {
658 $count = count( $tokens );
659
660 for ( $i = $from + $step; $i >= 0 && $i < $count; $i += $step ) {
661 if ( ! in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
662 return $i;
663 }
664 }
665
666 return null;
667 }
668
669 /**
670 * Redact a comment, keeping its plain words
671 *
672 * To the tokenizer a comment is text, and wp-config.php files keep old
673 * credentials in them, commented out or in a note. The first version of
674 * this, in the same release, redacted what was between quotes: an
675 * apostrophe in prose ("Don't use 'the-old-password'") paired with the
676 * opening quote of the secret and left it out, and a secret without quotes
677 * was never touched. Found by the cross review of 2.11.8.
678 *
679 * So it works the other way round. A comment keeps its plain words
680 * (lowercase, capitalised or uppercase letters, or two capitalised parts
681 * such as WordPress, and docblock tags), constant names, and anything
682 * shorter than eight characters; every other run of characters, a URL, a
683 * key, a password with a digit in it, becomes the marker. The value of a
684 * commented-out define() goes in single quotes whatever its length, as in
685 * code and as 2.11.2 to 2.11.7 wrote it, so a copy stored by those
686 * versions reads the same line for line. What this cannot tell from prose
687 * is a password made only of plain letters; the output check still
688 * catches it when it is a value in force.
689 *
690 * @since 2.11.8
691 *
692 * @param string $comment Comment token text.
693 * @return string
694 */
695 private function redact_comment( $comment ) {
696 $marker = self::REDACTED_MARKER;
697 $readable = self::$readable_constants;
698
699 // Only the text between the delimiters is redacted: "/**#@-*/" in
700 // wp-config-sample.php is a single run of eight characters, and
701 // replacing it whole took the comment markers with it.
702 if ( ! preg_match( '#\A(/\*\*?|//|\#)(.*?)(\*/)?\z#s', $comment, $parts ) ) {
703 $parts = array( $comment, '', $comment );
704 }
705
706 $open = $parts[1];
707 $close = isset( $parts[3] ) ? $parts[3] : '';
708 $comment = preg_replace_callback(
709 '/(\bdefine\s*\(\s*([\'"])((?:\\\\.|(?!\2).)*)\2\s*,\s*)([\'"])((?:\\\\.|(?!\4).)*)\4/i',
710 function ( $match ) use ( $marker, $readable ) {
711 return in_array( $match[3], $readable, true ) ? $match[0] : $match[1] . "'" . $marker . "'";
712 },
713 $parts[2]
714 );
715
716 if ( null === $comment ) {
717 return '';
718 }
719
720 $redacted = preg_replace_callback(
721 '/[^\s\'"`(),;\[\]{}<>=]+/u',
722 function ( $match ) use ( $marker ) {
723 $word = $match[0];
724 $core = rtrim( $word, '.:!?' );
725
726 // Plain words only, without hyphens: a passphrase written as
727 // lowercase words joined by hyphens reads as prose otherwise, and
728 // the PoC of this very fix caught one surviving.
729 if ( strlen( $core ) < 8
730 || preg_match( '/^@?(?:\p{Lu}?\p{Ll}+|\p{Lu}+)$/u', $core )
731 || preg_match( '/^\p{Lu}\p{Ll}+\p{Lu}\p{Ll}+$/u', $core )
732 || preg_match( '/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/', $core )
733 ) {
734 return $word;
735 }
736
737 return $marker . substr( $word, strlen( $core ) );
738 },
739 $comment
740 );
741
742 // A failed replacement, on invalid UTF-8 for one, drops the comment
743 // rather than keep it whole.
744 return ( null === $redacted ) ? '' : $open . $redacted . $close;
745 }
746
747 /**
748 * Values in force of what a wp-config.php names
749 *
750 * Every user constant whose name appears in the file, the twelve of
751 * WordPress wherever they were defined, and the environment variables the
752 * file reads or sets. Arrays are walked to their leaves, since define()
753 * takes arrays. Only strings: numbers are never redacted and are not
754 * secrets, and the first version of this counted them and wiped the diff
755 * of any file with a large number in force (cross review of 2.11.8). The
756 * readable constants are left out, and so is anything shorter than eight
757 * characters.
758 *
759 * @since 2.11.8
760 *
761 * @param string $content Normalized wp-config.php content.
762 * @return string[]
763 */
764 private function values_in_force( $content ) {
765 $defined = get_defined_constants( true );
766 $user = isset( $defined['user'] ) ? $defined['user'] : array();
767 $names = self::$secret_constants;
768 $values = array();
769
770 if ( preg_match_all( '/[A-Za-z_][A-Za-z0-9_]*/', (string) $content, $words ) ) {
771 $names = array_merge( $names, $words[0] );
772 }
773
774 foreach ( array_unique( $names ) as $name ) {
775 if ( array_key_exists( $name, $user ) && ! in_array( $name, self::$readable_constants, true ) ) {
776 $values = array_merge( $values, self::string_leaves( $user[ $name ] ) );
777 }
778 }
779
780 if ( preg_match_all( '/\b(?:getenv|putenv)\s*\(\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)|\$_ENV\s*\[\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)/', (string) $content, $env ) ) {
781 foreach ( array_filter( array_merge( $env[1], $env[2] ) ) as $name ) {
782 $value = getenv( $name );
783
784 if ( is_string( $value ) ) {
785 $values[] = $value;
786 }
787 }
788 }
789
790 $long = array();
791
792 foreach ( $values as $value ) {
793 if ( strlen( $value ) >= 8 ) {
794 $long[ $value ] = $value;
795 }
796 }
797
798 return array_values( $long );
799 }
800
801 /**
802 * Every string inside a constant value
803 *
804 * @since 2.11.8
805 *
806 * @param mixed $value Constant value.
807 * @return string[]
808 */
809 private static function string_leaves( $value ) {
810 if ( is_array( $value ) ) {
811 $leaves = array();
812
813 foreach ( $value as $item ) {
814 $leaves = array_merge( $leaves, self::string_leaves( $item ) );
815 }
816
817 return $leaves;
818 }
819
820 return is_string( $value ) ? array( $value ) : array();
821 }
822
823 /**
824 * Values in force of the readable constants, the ones kept in the copy
825 *
826 * @since 2.11.8
827 *
828 * @return string[]
829 */
830 private function readable_values_in_force() {
831 $values = array();
832
833 foreach ( self::$readable_constants as $name ) {
834 if ( defined( $name ) ) {
835 $values = array_merge( $values, self::string_leaves( constant( $name ) ) );
836 }
837 }
838
839 return $values;
840 }
841
842 /**
843 * Replace the values a root .htaccess can carry as credentials
844 *
845 * The .htaccess is not a secrets file, but it can hold a few: an
846 * environment variable handed to PHP with SetEnv, an Authorization header
847 * set for a backend, or a php_value with a password, a key, a licence or a
848 * session store address with its auth in it. The directive and its name
849 * stay, the value goes. Line based, which is how Apache reads it too. Since
850 * the cross review of 2.11.8 also any request or response header whose name
851 * reads like a credential (X-Api-Key, a cookie, a signature) and a
852 * RewriteCond that compares against key=, token= or the like, the way a
853 * staging site is opened with a secret in the query string. What it cannot
854 * see is a credential written in any other shape.
855 *
856 * @since 2.11.8
857 *
858 * @param string $content Normalized .htaccess content.
859 * @return string
860 */
861 private function redact_server_secrets( $content ) {
862 $redacted = preg_replace(
863 array(
864 '/^([ \t]*SetEnv[ \t]+\S+[ \t]+)\S.*$/mi',
865 '/^([ \t]*(?:RequestHeader|Header)[ \t]+(?:always[ \t]+)?\S+[ \t]+[\w-]*(?:auth|key|token|secret|pass|cookie|sig)[\w-]*[ \t]+)\S.*$/mi',
866 '/^([ \t]*php_(?:admin_)?value[ \t]+\S*(?:pass|pw|secret|key|token|licen|auth|save_path)\S*[ \t]+)\S.*$/mi',
867 '/^([ \t]*RewriteCond[ \t]+\S+[ \t]+)\S*(?:key|token|secret|pass|auth|sig)[\w-]*=\S*/mi',
868 ),
869 '${1}' . self::REDACTED_MARKER,
870 (string) $content
871 );
872
873 return ( null === $redacted ) ? '' : $redacted;
874 }
875
876 /**
877 * Promote a per-site baseline to the network record before dropping it
878 *
879 * Up to 2.11.2 the baseline was a per-site option, so on a network every
880 * site kept its own copy of the same two files. Those copies go, but what a
881 * copy records is which version of the file the owner approved, and that
882 * has to survive: rebuilding the baseline from disk would take whatever is
883 * there right now as approved, so a wp-config.php modified and still
884 * awaiting review would be blessed in silence.
885 *
886 * WHICH copy becomes the network record is not a detail, and 2.11.3 got it
887 * wrong. This runs from the scan, under wp-cron, on whichever site gets
888 * traffic first, and the sweep from the main site can be hours away because
889 * it waits for a network administrator to open a dashboard. So on a network
890 * with traffic spread around, the record of the whole installation was
891 * whatever the first subsite to scan happened to hold.
892 *
893 * That is harmless while every copy agrees, which is the ordinary case. The
894 * reason they can disagree is the very thing 2.11.3 fixed: until then,
895 * approving a change to wp-config.php took manage_options, which on a
896 * network the administrator of every subsite holds. If a change was
897 * approved on some subsite while the main site still had it pending review,
898 * promoting that subsite's copy retires a warning nobody decided to retire.
899 *
900 * Hence the order, file by file: what the network record already holds
901 * wins, then the main site, then the site this runs on. Between the copies,
902 * the main site beats a subsite, which is @calzbert's point, reported after
903 * reading the 2.11.3 diff.
904 *
905 * What this does NOT protect, said plainly because an earlier wording
906 * claimed more: if the network record already holds a file, that entry
907 * wins, even when it was written from disk by the .htaccess writer on
908 * init:20 while a third-party edit was pending review. What survives is a
909 * file the network record does not hold yet, which is the wp-config.php
910 * case that 2.11.3 lost. The .htaccess case is pre-existing and needs the
911 * writers to pass their before-hash, see update_critical_file_baseline().
912 *
913 * @since 2.11.4
914 *
915 * @param array|null $per_site Baseline stored for the site this runs on.
916 * @return bool True when the network record covers everything the per-site
917 * copy had, which is the only case where dropping it is safe.
918 */
919 private function promote_per_site_baseline( $per_site ) {
920 $network = get_site_option( self::BASELINE_OPTION, array() );
921
922 if ( ! is_array( $network ) ) {
923 $network = array();
924 }
925
926 /*
927 * Three sources, filled in one from another, file by file. It used to
928 * be all or nothing: if the network record existed at all, this
929 * returned at once and the caller dropped the per-site copy anyway.
930 *
931 * That looked safe and was not, because the network record can be born
932 * holding ONE of the two files. maybe_sync_server_files() runs on init
933 * and rewrites the root .htaccess by itself, and the writer calls
934 * update_critical_file_baseline( '.htaccess' ), which creates the
935 * network option with that single entry. init runs before admin_init,
936 * so on a network on Apache this is the ordinary order of an update,
937 * not a race: the cleanup then found the option "already there", kept
938 * nothing, and deleted the per-site copies that held the approved
939 * record of wp-config.php. The next scan met a file it had never seen
940 * and stored whatever was on disk as approved, which is the silent
941 * blessing this whole function exists to prevent. Reproduced on the
942 * Multisite install on 10 sep 2026, found by a cross review.
943 *
944 * Order of authority: what the network already says wins, then the main
945 * site, then the site this runs on. Nothing is ever overwritten and
946 * nothing is dropped for being late.
947 */
948 $sources = array( $network );
949
950 if ( ! is_main_site() ) {
951 $from_main = get_blog_option( get_main_site_id(), self::BASELINE_OPTION, null );
952
953 if ( is_array( $from_main ) ) {
954 $sources[] = $from_main;
955 }
956 }
957
958 if ( is_array( $per_site ) ) {
959 $sources[] = $per_site;
960 }
961
962 $merged = array();
963
964 foreach ( $sources as $source ) {
965 foreach ( $source as $filename => $data ) {
966 if ( isset( $merged[ $filename ] ) || ! is_array( $data ) || ! isset( $data['hash'] ) ) {
967 continue;
968 }
969
970 // Only the content carries secrets; the hash and the size,
971 // which are what say "this is the version that was approved",
972 // go over untouched.
973 if ( isset( $data['content'] ) && is_string( $data['content'] ) ) {
974 $data['content'] = $this->baseline_content( $filename, $data['content'] );
975 }
976
977 $merged[ $filename ] = $data;
978 }
979 }
980
981 if ( array_diff_key( $merged, $network ) ) {
982 $this->write_baseline( $merged );
983 }
984
985 if ( ! is_array( $per_site ) ) {
986 return true;
987 }
988
989 /*
990 * Is every file this copy had a record of now on the network record?
991 * Only then may the caller drop it. And the question is asked of what
992 * is STORED, not of $merged, which is only what this request MEANT to
993 * store. Asking $merged makes the answer true by construction, because
994 * the copy is one of the sources above, so the guard could never fire
995 * and redact_in_place() in the caller was unreachable code.
996 *
997 * The write does not always land, and the case that matters is not a
998 * broken database, it is the same race as the bug this function fixes.
999 * On a network updating from 2.11.2 the network option does not exist
1000 * yet, so update_network_option() takes the $old_value === false branch
1001 * and delegates to add_network_option() (wp-includes/option.php:2434).
1002 * If another request created the option in between, that call either
1003 * returns false without writing (option.php:2201) or, when this process
1004 * still holds "does not exist" in its own notoptions cache, skips the
1005 * check and INSERTs a second row: wp_sitemeta has no unique index on
1006 * meta_key, so the record ends up duplicated and get_network_option()
1007 * hands back whichever row comes first. Reproduced on the Multisite
1008 * install on 10 sep 2026, with the .htaccess writer of init:20 racing a
1009 * promotion: two rows, the approved hash of wp-config.php out of reach,
1010 * and the per-site copy deleted all the same. Found by a cross review.
1011 *
1012 * Both cache keys go before rereading, and that is not belt and braces.
1013 * add_network_option() caches the value it believes it wrote
1014 * (option.php:2221), so a plain read hands back the very array that did
1015 * not survive; and a stale notoptions would answer "no such option"
1016 * without touching the database, which reads as "nothing is covered".
1017 * Measured: without dropping the cache this guard still returns true.
1018 */
1019 $network_id = get_current_network_id();
1020 wp_cache_delete( $network_id . ':' . self::BASELINE_OPTION, 'site-options' );
1021 wp_cache_delete( $network_id . ':notoptions', 'site-options' );
1022
1023 $stored = get_site_option( self::BASELINE_OPTION, array() );
1024
1025 if ( ! is_array( $stored ) ) {
1026 return false;
1027 }
1028
1029 foreach ( $per_site as $filename => $data ) {
1030 if ( is_array( $data ) && isset( $data['hash'] ) && ! isset( $stored[ $filename ] ) ) {
1031 return false;
1032 }
1033 }
1034
1035 return true;
1036 }
1037
1038 /**
1039 * Strip the secrets from a per-site copy that cannot be dropped yet
1040 *
1041 * The copy stays because it holds the only record of an approved file, but
1042 * what it must not keep for one more minute is the database password and
1043 * the eight keys and salts. The two things are separable and this is where
1044 * they get separated.
1045 *
1046 * @since 2.11.4
1047 *
1048 * @param array $per_site Baseline stored for the current site.
1049 * @return void
1050 */
1051 private function redact_in_place( $per_site ) {
1052 $changed = false;
1053
1054 foreach ( $per_site as $filename => $data ) {
1055 if ( ! is_array( $data ) || ! isset( $data['content'] ) || ! is_string( $data['content'] ) ) {
1056 continue;
1057 }
1058
1059 $safe = $this->baseline_content( $filename, $data['content'] );
1060
1061 if ( $safe !== $data['content'] ) {
1062 $per_site[ $filename ]['content'] = $safe;
1063 $changed = true;
1064 }
1065 }
1066
1067 if ( $changed ) {
1068 update_option( self::BASELINE_OPTION, $per_site );
1069 }
1070 }
1071
1072 /**
1073 * Network option recording the version whose results cleanup walked the network
1074 *
1075 * @since 2.11.8
1076 */
1077 const RESULTS_SWEEP_OPTION = 'vigilante_results_sweep';
1078
1079 /**
1080 * Clean the stored scan results of every site of the network, once per version
1081 *
1082 * redact_stored_results() runs per site from admin_init and from the scan,
1083 * so a subsite with the module off whose dashboard nobody opens kept the
1084 * lines of wp-config.php its last scan stored, with whatever that version
1085 * failed to redact. Same gap 2.11.3 and 2.11.4 closed for the baseline copy;
1086 * found for the results by the cross review of 2.11.8. It runs from the
1087 * network sweep, for a network administrator on the main site, and has its
1088 * own marker because the baseline sweep is already done on every network
1089 * that updated through 2.11.4.
1090 *
1091 * @since 2.11.8
1092 */
1093 private function maybe_sweep_network_results() {
1094 if ( VIGILANTE_VERSION === get_site_option( self::RESULTS_SWEEP_OPTION ) ) {
1095 return;
1096 }
1097
1098 update_site_option( self::RESULTS_SWEEP_OPTION, VIGILANTE_VERSION );
1099
1100 $site_ids = get_sites(
1101 array(
1102 'fields' => 'ids',
1103 'number' => 0,
1104 'network_id' => get_current_network_id(),
1105 'update_site_meta_cache' => false,
1106 )
1107 );
1108
1109 foreach ( $site_ids as $site_id ) {
1110 switch_to_blog( $site_id );
1111 $this->redact_stored_results();
1112 restore_current_blog();
1113 }
1114 }
1115
1116 /**
1117 * The diff of a shared file as a site that does not own it gets it
1118 *
1119 * No lines, and a flag the screens read to say where the lines are.
1120 *
1121 * @since 2.11.8
1122 *
1123 * @return array
1124 */
1125 public static function network_only_diff() {
1126 return array(
1127 'added' => array(),
1128 'removed' => array(),
1129 'unavailable' => true,
1130 'network' => true,
1131 );
1132 }
1133
1134 /**
1135 * Take out of the last stored scan what the baseline copy no longer keeps
1136 *
1137 * The results of the last scan are an option of each site, and the diff of
1138 * a critical file travels inside them line by line, redacted the way the
1139 * version that ran the scan redacted. Until 2.11.7 that let FTP_PASS and
1140 * friends through, and on a network every subsite with the module on kept
1141 * its own copy of the lines. This runs once per version with the rest of
1142 * the cleanup:
1143 *
1144 * - Where the shared files do not belong to this site, no line is kept.
1145 * - Lines of wp-config.php are dropped. A single line cannot be read as
1146 * PHP reliably (half a heredoc is just words), and the next scan rebuilds
1147 * them from the whole file.
1148 * - Lines of .htaccess are directives, one per line, and are redacted in
1149 * place.
1150 *
1151 * @since 2.11.8
1152 */
1153 private function redact_stored_results() {
1154 $results = get_option( 'vigilante_last_integrity_results' );
1155
1156 if ( ! is_array( $results ) || empty( $results['modified'] ) || ! is_array( $results['modified'] ) ) {
1157 return;
1158 }
1159
1160 $owns = Vigilante_Settings::owns_shared_files();
1161 $changed = false;
1162
1163 foreach ( $results['modified'] as $index => $item ) {
1164 if ( ! is_array( $item ) || 'critical_config' !== ( $item['type'] ?? '' ) || ! isset( $item['diff'] ) || ! is_array( $item['diff'] ) ) {
1165 continue;
1166 }
1167
1168 if ( ! $owns ) {
1169 if ( empty( $item['diff']['network'] ) ) {
1170 $results['modified'][ $index ]['diff'] = self::network_only_diff();
1171 $changed = true;
1172 }
1173 continue;
1174 }
1175
1176 if ( 'wp-config.php' === ( $item['file'] ?? '' ) ) {
1177 if ( ! empty( $item['diff']['added'] ) || ! empty( $item['diff']['removed'] ) ) {
1178 $results['modified'][ $index ]['diff'] = array(
1179 'added' => array(),
1180 'removed' => array(),
1181 'unavailable' => true,
1182 'rescan' => true,
1183 );
1184 $changed = true;
1185 }
1186 continue;
1187 }
1188
1189 foreach ( array( 'added', 'removed' ) as $side ) {
1190 if ( empty( $item['diff'][ $side ] ) || ! is_array( $item['diff'][ $side ] ) ) {
1191 continue;
1192 }
1193
1194 foreach ( $item['diff'][ $side ] as $line_index => $line ) {
1195 if ( ! is_array( $line ) || ! isset( $line['content'] ) || ! is_string( $line['content'] ) ) {
1196 continue;
1197 }
1198
1199 $safe = $this->redact_server_secrets( $line['content'] );
1200
1201 if ( $safe !== $line['content'] ) {
1202 $results['modified'][ $index ]['diff'][ $side ][ $line_index ]['content'] = $safe;
1203 $changed = true;
1204 }
1205 }
1206 }
1207 }
1208
1209 if ( $changed ) {
1210 update_option( 'vigilante_last_integrity_results', $results );
1211 }
1212 }
1213
1214 /**
1215 * Clean up what earlier versions stored, wherever they stored it
1216 *
1217 * Two jobs, and the second one only exists on a network.
1218 *
1219 * The first: versions up to 2.11.1 kept the contents of wp-config.php in
1220 * the baseline, credentials included, so what is already on disk is
1221 * redacted in place.
1222 *
1223 * The second: up to 2.11.2 that baseline was a per-site option, so on a
1224 * network every site had its own copy of the same file. This runs per site
1225 * and removes that copy, because the baseline now lives in a single
1226 * network option. Doing it here is what makes the cleanup reach a site
1227 * whose dashboard nobody ever opens: this method is called from the scan
1228 * as well as from admin_init, and the scan runs on every site through
1229 * wp-cron with front-end traffic alone.
1230 *
1231 * The gate option stays per site on purpose. It records that THIS site has
1232 * been cleaned, which is exactly the per-site fact being tracked.
1233 *
1234 * @since 2.11.2
1235 */
1236 public function maybe_redact_stored_baseline() {
1237 if ( VIGILANTE_VERSION === get_option( self::BASELINE_REDACTION_OPTION ) ) {
1238 return;
1239 }
1240
1241 /*
1242 * The per-site copy left behind by 2.11.2 and earlier. On a network it
1243 * holds the database password and the eight keys and salts, so it goes,
1244 * but never before what it records has been carried over:
1245 * promote_per_site_baseline() explains why the record has to outlive
1246 * the copy, and which copy wins when they disagree. Measured on the
1247 * Multisite install while writing 2.11.3: without that, the first scan
1248 * after the migration reported zero modified files where it had to
1249 * report one.
1250 */
1251 $pending = false;
1252
1253 if ( is_multisite() ) {
1254 $per_site = get_option( self::BASELINE_OPTION, null );
1255
1256 if ( null !== $per_site ) {
1257 if ( $this->promote_per_site_baseline( $per_site ) ) {
1258 delete_option( self::BASELINE_OPTION );
1259 } elseif ( is_array( $per_site ) ) {
1260 // Something this copy recorded is not on the network record
1261 // yet, so it does not go: it is the only evidence of what
1262 // was approved. The secrets do go, right now, because that
1263 // part cannot wait for the next pass.
1264 $this->redact_in_place( $per_site );
1265 $pending = true;
1266 }
1267 }
1268 }
1269
1270 $baseline = $this->read_baseline();
1271
1272 if ( is_array( $baseline ) ) {
1273 $changed = false;
1274
1275 foreach ( $baseline as $filename => $data ) {
1276 if ( ! is_array( $data ) || ! isset( $data['content'] ) || ! is_string( $data['content'] ) ) {
1277 continue;
1278 }
1279
1280 $safe = $this->baseline_content( $filename, $data['content'] );
1281
1282 if ( $safe !== $data['content'] ) {
1283 $baseline[ $filename ]['content'] = $safe;
1284 $changed = true;
1285 }
1286 }
1287
1288 if ( $changed ) {
1289 $this->write_baseline( $baseline );
1290 }
1291 }
1292
1293 $this->redact_stored_results();
1294
1295 /*
1296 * The gate does not close while a per-site copy is still waiting to be
1297 * promoted. Closing it would end the retries for a whole version: the
1298 * copy would sit there unread, the file it records would be missing
1299 * from the network record, and the next scan would take whatever is on
1300 * disk as approved. Not closing it is not free, though, and the first
1301 * wording here said "one option read": measured cold on the Multisite
1302 * install, it is 6 SQL queries per admin request against 0 with the gate
1303 * closed, admin-ajax.php and the heartbeat included, two of them from the
1304 * cache invalidation in promote_per_site_baseline(). Acceptable only
1305 * because it converges: the stuck case this guards against resolves on
1306 * the next pass that gets its write through.
1307 */
1308 if ( ! $pending ) {
1309 update_option( self::BASELINE_REDACTION_OPTION, VIGILANTE_VERSION, false );
1310 }
1311 }
1312
1313 /**
1314 * Sweep the whole network once, so it does not wait for each site's cron
1315 *
1316 * The per-site cleanup above reaches a site when that site runs a scan or
1317 * someone opens its dashboard, which on a quiet subsite can take a while.
1318 * This walks every site once and gets it over with, and its marker is a
1319 * network option so it does not repeat per site.
1320 *
1321 * Runs only on the main site of the network, where a network administrator
1322 * works, and only there does it cost anything.
1323 *
1324 * @since 2.11.3
1325 */
1326 public function maybe_sweep_network_baselines() {
1327 if ( ! is_multisite() || ! is_main_site() ) {
1328 return;
1329 }
1330
1331 /*
1332 * A network administrator, and nobody else. admin_init fires for any
1333 * logged-in visitor, a subscriber opening their own profile included,
1334 * and this walks every site of the network writing to each one. What it
1335 * removes is stale data that rebuilds itself, so the harm is small, but
1336 * an action over the whole network belongs to whoever administers the
1337 * network. The surface inventory cannot see this: it reads wp_ajax_*,
1338 * admin_post_* and REST routes, and a hook on admin_init is outside its
1339 * coverage by construction, which is exactly the blind spot written
1340 * down as rule 23.
1341 *
1342 * The per-site cleanup is deliberately not gated the same way: it also
1343 * runs from the scan, under wp-cron with no user at all, and it only
1344 * touches the site it runs on.
1345 */
1346 if ( ! current_user_can( 'manage_network_options' ) ) {
1347 return;
1348 }
1349
1350 $this->maybe_sweep_network_results();
1351
1352 $marker = get_site_option( self::BASELINE_SWEEP_OPTION );
1353
1354 /*
1355 * Two markers, because there are two different things to remember and
1356 * 2.11.3 only remembered one of them.
1357 *
1358 * The walk is marked BEFORE it starts, on purpose: on a very large
1359 * network it may not finish inside one request, and repeating it on
1360 * every admin page load would be worse than leaving the rest to each
1361 * site's own scan. But 2.11.3 wrote VIGILANTE_VERSION there, so an
1362 * interrupted walk was retried by the next release, which was the only
1363 * thing that ever finished it. Writing a fixed literal instead, as the
1364 * first draft of 2.11.4 did, stopped the pointless rearming and took
1365 * that retry away with it: a walk cut short would never be resumed by
1366 * any version. And "each site's own scan cleans the rest" only holds
1367 * where the module is on; with it off, the cleanup is registered under
1368 * is_admin() alone, so a subsite nobody opens is exactly what the sweep
1369 * exists for. Found by a cross review on 10 sep 2026.
1370 *
1371 * So: the running version means "started here and did not finish", and
1372 * the migration literal means "finished, never again".
1373 */
1374 if ( self::BASELINE_SWEEP_MIGRATION === $marker ) {
1375 return;
1376 }
1377
1378 if ( VIGILANTE_VERSION === $marker ) {
1379 return;
1380 }
1381
1382 update_site_option( self::BASELINE_SWEEP_OPTION, VIGILANTE_VERSION );
1383
1384 // Only this network. WP_Site_Query filters by network solely when
1385 // network_id is given, so on a multi-network install the walk would
1386 // otherwise reach the sites of other networks and promote their copies
1387 // into this network's record (get_current_network_id() below does not
1388 // change with switch_to_blog()).
1389 $site_ids = get_sites(
1390 array(
1391 'fields' => 'ids',
1392 'number' => 0,
1393 'network_id' => get_current_network_id(),
1394 'update_site_meta_cache' => false,
1395 )
1396 );
1397
1398 $pending = 0;
1399
1400 foreach ( $site_ids as $site_id ) {
1401 switch_to_blog( $site_id );
1402
1403 $per_site = get_option( self::BASELINE_OPTION, null );
1404
1405 if ( null !== $per_site ) {
1406 // Same care as the per-site cleanup, and the same helper, so
1407 // the two paths cannot drift apart the way they nearly did.
1408 if ( $this->promote_per_site_baseline( $per_site ) ) {
1409 delete_option( self::BASELINE_OPTION );
1410 } elseif ( is_array( $per_site ) ) {
1411 $this->redact_in_place( $per_site );
1412 $pending++;
1413 }
1414 }
1415
1416 restore_current_blog();
1417 }
1418
1419 // Finished, and with nothing left behind, so it never has to run again
1420 // in any version. A site whose copy could not be promoted keeps the
1421 // marker on the running version instead, which is what gets the walk
1422 // retried by the next release.
1423 if ( ! $pending ) {
1424 update_site_option( self::BASELINE_SWEEP_OPTION, self::BASELINE_SWEEP_MIGRATION );
1425 }
1426 }
1427
1428 /**
1429 * Critical root files to monitor against a stored baseline.
1430 * These files have no official WordPress.org checksum because their
1431 * content is unique per installation.
1432 *
1433 * @var array
1434 */
1435 private $critical_root_files = array(
1436 'wp-config.php',
1437 '.htaccess',
1438 );
1439
1440 /**
1441 * Vigilante markers used in wp-config.php (constants block).
1442 *
1443 * @var array
1444 */
1445 private $wpconfig_markers = array(
1446 array( '/* BEGIN Vigilante Security Constants */', '/* END Vigilante Security Constants */' ),
1447 array( '/* BEGIN AyudaWP Security Constants */', '/* END AyudaWP Security Constants */' ),
1448 );
1449
1450 /**
1451 * Vigilante marker for commented-out original constants in wp-config.php.
1452 *
1453 * @var string
1454 */
1455 private $wpconfig_original_marker = '// [VIGILANTE_ORIGINAL] ';
1456
1457 /**
1458 * Vigilante markers used in .htaccess (firewall + security headers).
1459 *
1460 * @var array
1461 */
1462 private $htaccess_markers = array(
1463 array( '# BEGIN Vigilante Protection', '# END Vigilante Protection' ),
1464 array( '# BEGIN Vigilante Security Headers', '# END Vigilante Security Headers' ),
1465 );
1466
1467 /**
1468 * Core files known to produce false positives in checksum comparison.
1469 * These are skipped during core scanning (e.g. version.php is rewritten
1470 * during auto-updates and localized installs, readme files vary by locale).
1471 *
1472 * @var array
1473 */
1474 private $core_known_false_positives = array(
1475 'wp-includes/version.php',
1476 'readme.html',
1477 'license.txt',
1478 'licencia.txt',
1479 );
1480
1481 /**
1482 * Plugin files known to produce false positives in checksum comparison.
1483 * Readme files frequently differ between WordPress.org API checksums and
1484 * the actual installed version due to encoding, line endings, or locale.
1485 *
1486 * @var array
1487 */
1488 private $plugin_known_false_positives = array(
1489 'readme.txt',
1490 'readme.md',
1491 );
1492
1493 /**
1494 * Legitimate non-PHP files commonly found in WordPress root.
1495 * These are reported as 'additional' (informational), not suspicious.
1496 * Dotfiles (e.g. .htaccess) are skipped entirely by the root scanner.
1497 *
1498 * @var array
1499 */
1500 private $known_safe_root_files = array(
1501 'robots.txt',
1502 'security.txt',
1503 'humans.txt',
1504 'llms.txt',
1505 'llms-full.txt',
1506 'ads.txt',
1507 'app-ads.txt',
1508 'favicon.ico',
1509 'favicon.png',
1510 'favicon.svg',
1511 'apple-touch-icon.png',
1512 'apple-touch-icon-precomposed.png',
1513 'sitemap.xml',
1514 'sitemap_index.xml',
1515 'bingsiteauth.xml',
1516 'livesearchsiteauth.xml',
1517 'google-site-verification.html',
1518 'php.ini',
1519 // PHP error logs commonly created by managed hosting (SiteGround, Hostinger, cPanel).
1520 // Not executable; reported as "additional" instead of "suspicious".
1521 'php_errorlog',
1522 'error_log',
1523 );
1524
1525 /**
1526 * Legacy WordPress core files removed from newer versions but kept on
1527 * existing installs to prevent breakage. These are dead code, not malware.
1528 * Marked as 'extra' (additional) instead of 'suspicious' with advice to delete.
1529 *
1530 * @see https://core.trac.wordpress.org/ticket/48540
1531 * @see https://core.trac.wordpress.org/ticket/18384
1532 * @var array
1533 */
1534 private $legacy_core_root_files = array(
1535 'wp-feed.php',
1536 'wp-rss.php',
1537 'wp-rss2.php',
1538 'wp-rdf.php',
1539 'wp-atom.php',
1540 'wp-commentsrss2.php',
1541 'wp-pass.php',
1542 'wp-register.php',
1543 );
1544
1545 /**
1546 * Constructor
1547 *
1548 * @param Vigilante_Settings $settings Settings instance.
1549 * @param Vigilante_Database|null $database Database instance.
1550 * @param Vigilante_Activity_Log|null $activity_log Activity log instance.
1551 */
1552 public function __construct( $settings, $database = null, $activity_log = null ) {
1553 $this->settings = $settings;
1554 $this->database = $database;
1555 $this->activity_log = $activity_log;
1556 $this->options = $settings ? $settings->get_section( 'file_integrity' ) : array();
1557 $this->wp_version = get_bloginfo( 'version' );
1558 $this->ignored_files = get_option( 'vigilante_ignored_files', array() );
1559 }
1560
1561 /**
1562 * Register the hooks of the scanner itself
1563 *
1564 * Until 2.11.4 all of this lived in the constructor, and the constructor is
1565 * called from a dozen places: the module gate, the activator, the hook that
1566 * runs after Vigilant writes a watched file, and the admin handlers that
1567 * only want the class as a tool. Every
1568 * one of them registered these hooks again, and one runs during admin_init
1569 * itself. Registering apart from constructing means a `new` is only a
1570 * `new`, and it is what lets the cleanup below stand on its own.
1571 *
1572 * @since 2.11.4
1573 */
1574 public function init_hooks() {
1575 // Schedule automated scans only if options available
1576 if ( ! empty( $this->options['auto_scan'] ) ) {
1577 add_action( 'vigilante_file_integrity_scan', array( $this, 'run_scheduled_scan' ) );
1578 $this->schedule_scan();
1579 }
1580
1581 // Post-update verification: verify any just-updated plugin/theme against
1582 // WordPress.org immediately, and open a short grace window so the
1583 // scheduled scan does not raise false positives while wp.org is still
1584 // publishing the new version's checksums. Registered regardless of
1585 // auto_scan because it reacts to update events, not to the schedule.
1586 add_action( 'upgrader_process_complete', array( $this, 'on_upgrade_complete' ), 20, 2 );
1587 add_action( 'vigilante_fi_postupdate_verify', array( $this, 'run_postupdate_verify' ) );
1588 }
1589
1590 /**
1591 * Register the cleanup of what earlier versions stored, module on or off
1592 *
1593 * These two are not integrity monitoring. They take out of the database
1594 * something the plugin stored and should not have, which is the copy of
1595 * wp-config.php carrying the database password and the eight keys and
1596 * salts. Whoever switched the module off did not decide to keep that, and
1597 * for that person the cleanup matters more, not less: they are not going
1598 * to pass through the scanner again.
1599 *
1600 * Until 2.11.4 these were registered in the constructor, so they only ran
1601 * where the module was on. A site with the module off kept the credentials
1602 * with 2.11.3 installed, and a network whose main site had it off lost the
1603 * sweep too, which was the one path that reached the sites nobody visits.
1604 * Reported by @calzbert after reading the 2.11.3 diff.
1605 *
1606 * On admin_init because that is where the baseline is looked at, and it
1607 * does one option read per admin request until it has run once.
1608 *
1609 * @since 2.11.4
1610 */
1611 public function init_cleanup_hooks() {
1612 add_action( 'admin_init', array( $this, 'maybe_redact_stored_baseline' ) );
1613 add_action( 'admin_init', array( $this, 'maybe_sweep_network_baselines' ) );
1614 add_action( 'admin_init', array( $this, 'maybe_claim_owned_blocks' ) );
1615 }
1616
1617 /**
1618 * Check if scan time limit has been exceeded
1619 *
1620 * @return bool True if time exceeded.
1621 */
1622 private function is_time_exceeded() {
1623 if ( 0 === $this->scan_start_time ) {
1624 return false;
1625 }
1626 return ( microtime( true ) - $this->scan_start_time ) > $this->max_scan_time;
1627 }
1628
1629 /**
1630 * Schedule automated scans
1631 */
1632 private function schedule_scan() {
1633 $frequency = $this->options['scan_frequency'] ?? 'daily';
1634
1635 if ( ! wp_next_scheduled( 'vigilante_file_integrity_scan' ) ) {
1636 wp_schedule_event( time(), $frequency, 'vigilante_file_integrity_scan' );
1637 }
1638 }
1639
1640 /**
1641 * Run a scheduled scan
1642 */
1643 public function run_scheduled_scan() {
1644 $results = $this->run_scan();
1645
1646 // Store last scan time
1647 update_option( 'vigilante_last_integrity_scan', time() );
1648 update_option( 'vigilante_last_integrity_results', $results );
1649 }
1650
1651 /**
1652 * React to a completed plugin/theme update (upgrader_process_complete).
1653 *
1654 * Opens a short grace window for each updated slug (so the scheduled scan
1655 * skips it and the checksum cache is bypassed) and schedules an immediate
1656 * verification against WordPress.org. This stops the post-update "files
1657 * don't match WordPress.org" false positives and, when WP-Cron is healthy,
1658 * verifies the update against WordPress.org right away instead of waiting
1659 * for the next scheduled scan. The grace window is intentionally short (30
1660 * minutes) so that if WP-Cron never fires the verification, the normal scan
1661 * resumes for the slug rather than leaving it unscanned.
1662 *
1663 * Runs as the old plugin code with the new files already on disk, so slugs
1664 * are read from $hook_extra, not from in-memory version constants.
1665 *
1666 * @param WP_Upgrader|mixed $upgrader Upgrader instance (unused).
1667 * @param array $hook_extra Update context.
1668 */
1669 public function on_upgrade_complete( $upgrader, $hook_extra ) {
1670 unset( $upgrader );
1671 if ( ! is_array( $hook_extra ) || 'update' !== ( $hook_extra['action'] ?? '' ) ) {
1672 return;
1673 }
1674
1675 $type = $hook_extra['type'] ?? '';
1676 $targets = array(); // type => list of slugs.
1677
1678 if ( 'plugin' === $type ) {
1679 $files = array();
1680 if ( ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
1681 $files = $hook_extra['plugins'];
1682 } elseif ( ! empty( $hook_extra['plugin'] ) ) {
1683 $files = array( $hook_extra['plugin'] );
1684 }
1685 foreach ( $files as $file ) {
1686 $slug = dirname( (string) $file );
1687 if ( '.' !== $slug && '' !== $slug ) {
1688 $targets['plugin'][] = $slug;
1689 }
1690 }
1691 } elseif ( 'theme' === $type && ! empty( $hook_extra['themes'] ) && is_array( $hook_extra['themes'] ) ) {
1692 foreach ( $hook_extra['themes'] as $slug ) {
1693 $slug = (string) $slug;
1694 if ( '' !== $slug ) {
1695 $targets['theme'][] = $slug;
1696 }
1697 }
1698 }
1699
1700 if ( empty( $targets ) ) {
1701 return;
1702 }
1703
1704 // Open a short grace window per slug (30 minutes; closed earlier once the
1705 // verifier runs). Kept short on purpose: with WP-Cron disabled the
1706 // verifier never fires, so a longer window would leave a just-updated
1707 // slug unscanned. After it expires the normal scan resumes.
1708 foreach ( $targets as $t => $slugs ) {
1709 foreach ( array_unique( $slugs ) as $slug ) {
1710 set_transient( 'vigilante_fi_grace_' . $t . '_' . md5( $slug ), 1, 30 * MINUTE_IN_SECONDS );
1711 }
1712 }
1713
1714 // Verify shortly after the update settles. A single delayed event keeps
1715 // the heavy work out of the update request itself.
1716 if ( ! wp_next_scheduled( 'vigilante_fi_postupdate_verify', array( $targets ) ) ) {
1717 wp_schedule_single_event( time() + 90, 'vigilante_fi_postupdate_verify', array( $targets ) );
1718 }
1719 }
1720
1721 /**
1722 * Immediate post-update verification callback (vigilante_fi_postupdate_verify).
1723 *
1724 * @param array $targets type => list of slugs.
1725 */
1726 public function run_postupdate_verify( $targets ) {
1727 if ( ! is_array( $targets ) ) {
1728 return;
1729 }
1730 foreach ( $targets as $type => $slugs ) {
1731 if ( 'plugin' !== $type && 'theme' !== $type ) {
1732 continue;
1733 }
1734 foreach ( array_unique( (array) $slugs ) as $slug ) {
1735 $this->verify_updated_slug( $type, (string) $slug );
1736 }
1737 }
1738 }
1739
1740 /**
1741 * Verify a single just-updated plugin/theme against fresh wp.org checksums.
1742 *
1743 * The grace window forces get_*_checksums() to fetch a live manifest, so this
1744 * never compares against a manifest cached during wp.org's propagation lag.
1745 * Outcomes: checksums not published yet => leave the window to expire and let
1746 * the next scan re-check; all files match => close the window early; a file
1747 * matches no published hash => a genuine mismatch (right after a legit update
1748 * that points at a tampered package), logged as a warning, and the window is
1749 * closed so the finding also surfaces in the normal scan.
1750 *
1751 * @param string $type 'plugin' or 'theme'.
1752 * @param string $slug Slug.
1753 */
1754 private function verify_updated_slug( $type, $slug ) {
1755 $grace_key = 'vigilante_fi_grace_' . $type . '_' . md5( $slug );
1756
1757 if ( 'plugin' === $type ) {
1758 if ( ! function_exists( 'get_plugins' ) ) {
1759 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1760 }
1761 $version = '';
1762 $name = $slug;
1763 foreach ( get_plugins() as $file => $data ) {
1764 if ( dirname( $file ) === $slug ) {
1765 $version = $data['Version'] ?? '';
1766 $name = $data['Name'] ?? $slug;
1767 break;
1768 }
1769 }
1770 $checksums = $this->get_plugin_checksums( $slug, $version );
1771 $base_dir = WP_PLUGIN_DIR . '/' . $slug;
1772 } else {
1773 $theme = wp_get_theme( $slug );
1774 if ( ! $theme->exists() ) {
1775 delete_transient( $grace_key );
1776 return;
1777 }
1778 $version = $theme->get( 'Version' );
1779 $name = $theme->get( 'Name' );
1780 $checksums = $this->get_theme_checksums( $slug, $version );
1781 $base_dir = $theme->get_stylesheet_directory();
1782 }
1783
1784 // Checksums not available yet (propagation lag): leave the grace window
1785 // to expire; the next scheduled scan re-verifies once wp.org publishes.
1786 if ( is_wp_error( $checksums ) || 'not_found' === $checksums || ! is_array( $checksums ) ) {
1787 if ( $this->activity_log ) {
1788 $this->activity_log->log(
1789 'file',
1790 'postupdate_pending',
1791 sprintf(
1792 /* translators: 1: Plugin or theme name. */
1793 __( 'Post-update verification pending for %1$s: WordPress.org has not published the new version checksums yet. It will be re-verified automatically.', 'vigilante' ),
1794 $name
1795 ),
1796 array(
1797 'type' => $type,
1798 'slug' => $slug,
1799 'version' => $version,
1800 ),
1801 'info'
1802 );
1803 }
1804 return;
1805 }
1806
1807 // Compare every shipped file against the fresh manifest.
1808 $mismatched = array();
1809 foreach ( $checksums as $file => $expected ) {
1810 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
1811 continue;
1812 }
1813 $path = $base_dir . '/' . $file;
1814 if ( $this->is_path_excluded( $path ) || $this->is_extension_excluded( $path ) ) {
1815 continue;
1816 }
1817 // Honor the user ignore list, exactly as run_scan()'s filter_ignored()
1818 // does, so the verifier never warns about a file the user silenced.
1819 $rel = $type . 's/' . $slug . '/' . $file;
1820 if ( in_array( $rel, (array) $this->ignored_files, true ) ) {
1821 continue;
1822 }
1823 if ( ! file_exists( $path ) ) {
1824 continue;
1825 }
1826 if ( ! $this->hash_matches_published( $path, $expected ) ) {
1827 $mismatched[] = $rel;
1828 }
1829 }
1830
1831 // Verified clean: close the grace window so normal scanning resumes.
1832 if ( empty( $mismatched ) ) {
1833 delete_transient( $grace_key );
1834 if ( $this->activity_log ) {
1835 $this->activity_log->log(
1836 'file',
1837 'postupdate_verified',
1838 sprintf(
1839 /* translators: 1: Plugin or theme name. */
1840 __( 'Post-update verification passed: %1$s matches the WordPress.org distribution.', 'vigilante' ),
1841 $name
1842 ),
1843 array(
1844 'type' => $type,
1845 'slug' => $slug,
1846 'version' => $version,
1847 ),
1848 'info'
1849 );
1850 }
1851 return;
1852 }
1853
1854 // Genuine mismatch right after a legitimate update: likely a tampered
1855 // package. Close the window so the normal scan also surfaces it, and log
1856 // a warning (Audit Alerts escalates warnings if configured).
1857 delete_transient( $grace_key );
1858 if ( $this->activity_log ) {
1859 $this->activity_log->log(
1860 'file',
1861 'postupdate_mismatch',
1862 sprintf(
1863 /* translators: 1: Number of files, 2: Plugin or theme name. */
1864 _n(
1865 'Post-update integrity check failed: %1$d file in %2$s does not match the WordPress.org distribution.',
1866 'Post-update integrity check failed: %1$d files in %2$s do not match the WordPress.org distribution.',
1867 count( $mismatched ),
1868 'vigilante'
1869 ),
1870 count( $mismatched ),
1871 $name
1872 ),
1873 array(
1874 'type' => $type,
1875 'slug' => $slug,
1876 'version' => $version,
1877 'files' => array_slice( $mismatched, 0, 50 ),
1878 ),
1879 'warning'
1880 );
1881 }
1882 }
1883
1884 /**
1885 * Run a full integrity scan
1886 *
1887 * @return array Scan results.
1888 */
1889 public function run_scan() {
1890 // Initialize scan timer
1891 $this->scan_start_time = microtime( true );
1892
1893 $results = array(
1894 'scanned' => 0,
1895 'ok' => 0,
1896 'modified' => array(),
1897 'missing' => array(),
1898 'suspicious' => array(),
1899 'extra' => array(),
1900 'new' => array(),
1901 'errors' => array(),
1902 'scan_time' => 0,
1903 'incomplete' => false,
1904 );
1905
1906 // Use settings from options page
1907 $options = is_array( $this->options ) ? $this->options : array();
1908
1909 // Scan uploads for suspicious files FIRST (highest security priority)
1910 // PHP files in uploads are almost always malware
1911 if ( ! empty( $options['scan_uploads'] ) && ! $this->is_time_exceeded() ) {
1912 $upload_results = $this->scan_uploads();
1913 $results['suspicious'] = array_merge( $results['suspicious'], $upload_results['suspicious'] );
1914 $results['extra'] = array_merge( $results['extra'], $upload_results['extra'] );
1915 }
1916
1917 // Scan core files
1918 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
1919 $core_results = $this->scan_core_files();
1920 $results = $this->merge_results( $results, $core_results );
1921 }
1922
1923 // Scan root directory for non-core files (PHP = suspicious, others = additional)
1924 // Runs after core scan so checksums are already cached
1925 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
1926 $root_results = $this->scan_root_files();
1927 $results['suspicious'] = array_merge( $results['suspicious'], $root_results['suspicious'] );
1928 $results['extra'] = array_merge( $results['extra'], $root_results['extra'] );
1929 }
1930
1931 // Scan critical config files (wp-config.php, .htaccess) against stored baseline
1932 if ( ! empty( $options['scan_critical_config'] ) && ! $this->is_time_exceeded() ) {
1933 $critical_results = $this->scan_critical_root_files();
1934 $results['modified'] = array_merge( $results['modified'], $critical_results );
1935 }
1936
1937 // Scan plugins
1938 if ( ! empty( $options['scan_plugins'] ) && ! $this->is_time_exceeded() ) {
1939 $plugin_results = $this->scan_plugins();
1940 $results = $this->merge_results( $results, $plugin_results );
1941 }
1942
1943 // Scan themes
1944 if ( ! empty( $options['scan_themes'] ) && ! $this->is_time_exceeded() ) {
1945 $theme_results = $this->scan_themes();
1946 $results = $this->merge_results( $results, $theme_results );
1947 }
1948
1949 // Mark as incomplete if time was exceeded
1950 if ( $this->is_time_exceeded() ) {
1951 $results['incomplete'] = true;
1952 $results['errors'][] = __( 'Scan was incomplete due to time limit. Results may be partial.', 'vigilante' );
1953 }
1954
1955 // Filter out ignored files from all result categories
1956 $results['modified'] = $this->filter_ignored( $results['modified'] );
1957 $results['suspicious'] = $this->filter_ignored( $results['suspicious'] );
1958 $results['extra'] = $this->filter_ignored( $results['extra'] );
1959
1960 $results['scan_time'] = round( microtime( true ) - $this->scan_start_time, 2 );
1961
1962 // Log the scan only if activity_log is available
1963 if ( $this->activity_log ) {
1964 $has_issues = ! empty( $results['modified'] ) || ! empty( $results['suspicious'] ) || ! empty( $results['extra'] );
1965 $severity = $has_issues ? 'warning' : 'info';
1966
1967 $this->activity_log->log(
1968 'file',
1969 'integrity_scan',
1970 sprintf(
1971 /* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */
1972 __( 'File integrity scan completed: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' ),
1973 $results['scanned'],
1974 count( $results['modified'] ),
1975 count( $results['suspicious'] ),
1976 count( $results['extra'] )
1977 ),
1978 array(
1979 'scanned' => $results['scanned'],
1980 'modified' => count( $results['modified'] ),
1981 'suspicious' => count( $results['suspicious'] ),
1982 'extra' => count( $results['extra'] ),
1983 'scan_time' => $results['scan_time'],
1984 'incomplete' => $results['incomplete'],
1985 ),
1986 $severity
1987 );
1988 }
1989
1990 // Closed plugins check: queries the wp.org repository for the closure status
1991 // of every installed plugin slug. Independent of the file-level scan_* toggles
1992 // (gated by its own `check_closed_plugins` toggle in Scan Scope). Runs BEFORE
1993 // the notification call so closed plugins are folded into the scan email
1994 // (instead of triggering a separate one-shot). Quick (~10 s for 50 plugins).
1995 //
1996 // suppress_email=true: this entry point is the file integrity scan; the
1997 // daily plugin-status cron passes suppress_email=false so urgent closures
1998 // still produce an immediate alert when the file scan is on a weekly schedule.
1999 if ( ! empty( $options['check_closed_plugins'] ) ) {
2000 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
2001 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
2002 }
2003 $closed_checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
2004 $closed_checker->check_all_plugins( true, true );
2005 }
2006
2007 // Send email notification based on notify_level (now also includes closed
2008 // plugins picked up just above).
2009 $this->maybe_send_notification( $results );
2010
2011 return $results;
2012 }
2013
2014 /**
2015 * Scan WordPress core files
2016 *
2017 * @return array Scan results.
2018 */
2019 private function scan_core_files() {
2020 $results = array(
2021 'scanned' => 0,
2022 'ok' => 0,
2023 'modified' => array(),
2024 'missing' => array(),
2025 'errors' => array(),
2026 );
2027
2028 // Get official checksums from WordPress.org
2029 $checksums = $this->get_core_checksums();
2030
2031 if ( is_wp_error( $checksums ) ) {
2032 $results['errors'][] = $checksums->get_error_message();
2033 return $results;
2034 }
2035
2036 foreach ( $checksums as $file => $expected_hash ) {
2037 // Check time limit
2038 if ( $this->is_time_exceeded() ) {
2039 break;
2040 }
2041
2042 $file_path = ABSPATH . $file;
2043
2044 // Skip excluded paths
2045 if ( $this->is_path_excluded( $file_path ) ) {
2046 continue;
2047 }
2048
2049 // Skip excluded extensions
2050 if ( $this->is_extension_excluded( $file_path ) ) {
2051 continue;
2052 }
2053
2054 // Skip known false positives (e.g. version.php, readme.html)
2055 if ( in_array( $file, $this->core_known_false_positives, true ) ) {
2056 continue;
2057 }
2058
2059 // Skip translations that travel inside the localized core ZIP but do
2060 // not belong to core. WordPress.org's localized checksum manifest
2061 // lists Akismet and the default themes' language files (8 entries on
2062 // every non-en_US locale, none on en_US), yet they are updated on the
2063 // plugin and theme cycle and are absent from the core language pack.
2064 // Deleting an unused plugin or theme, which this plugin's own audit
2065 // recommends, otherwise left permanent "missing core file" findings.
2066 if ( 0 === strpos( $file, 'wp-content/languages/plugins/' )
2067 || 0 === strpos( $file, 'wp-content/languages/themes/' ) ) {
2068 continue;
2069 }
2070
2071 $results['scanned']++;
2072
2073 if ( ! file_exists( $file_path ) ) {
2074 $results['missing'][] = array(
2075 'file' => $file,
2076 'type' => 'core',
2077 );
2078 continue;
2079 }
2080
2081 $actual_hash = md5_file( $file_path );
2082
2083 if ( $actual_hash !== $expected_hash ) {
2084 $results['modified'][] = array(
2085 'file' => $file,
2086 'type' => 'core',
2087 'expected_hash' => $expected_hash,
2088 'actual_hash' => $actual_hash,
2089 );
2090 } else {
2091 $results['ok']++;
2092 }
2093 }
2094
2095 $results['modified'] = $this->drop_stale_language_mismatches( $results['modified'], $results );
2096
2097 return $results;
2098 }
2099
2100 /**
2101 * Drop language files that only mismatch because the manifest was stale
2102 *
2103 * wp.org rebuilds the checksum manifest every time GlotPress rebuilds a
2104 * language pack, and that happens without the WordPress version moving. The
2105 * cache key here is version plus locale, so it does not expire when that
2106 * happens, and the site spends up to a day comparing today's translation
2107 * files against yesterday's manifest. That is where the bursts of "modified
2108 * core files" under wp-content/languages/ come from, and they are not
2109 * modifications at all.
2110 *
2111 * So before reporting one, the manifest is fetched again bypassing the
2112 * cache, once per scan, and whatever matches the fresh copy is dropped.
2113 * Anything still mismatching is reported as before.
2114 *
2115 * @since 2.9.9
2116 *
2117 * @param array $modified Entries flagged as modified.
2118 * @param array $results Scan results, to move the recovered files to 'ok'.
2119 * @return array Entries that are still modified.
2120 */
2121 private function drop_stale_language_mismatches( $modified, &$results ) {
2122 if ( empty( $modified ) ) {
2123 return $modified;
2124 }
2125
2126 $suspects = array();
2127 foreach ( $modified as $entry ) {
2128 if ( 0 === strpos( $entry['file'], 'wp-content/languages/' ) ) {
2129 $suspects[ $entry['file'] ] = true;
2130 }
2131 }
2132
2133 if ( empty( $suspects ) ) {
2134 return $modified;
2135 }
2136
2137 $fresh = $this->get_core_checksums( true );
2138
2139 if ( is_wp_error( $fresh ) || empty( $fresh ) ) {
2140 return $modified;
2141 }
2142
2143 $kept = array();
2144
2145 foreach ( $modified as $entry ) {
2146 $file = $entry['file'];
2147
2148 if ( ! isset( $suspects[ $file ] ) || ! isset( $fresh[ $file ] ) ) {
2149 $kept[] = $entry;
2150 continue;
2151 }
2152
2153 if ( $this->hash_matches_published( ABSPATH . $file, $fresh[ $file ] ) ) {
2154 $results['ok']++;
2155 continue;
2156 }
2157
2158 $kept[] = $entry;
2159 }
2160
2161 return $kept;
2162 }
2163
2164 /**
2165 * Scan WordPress root directory for non-core files
2166 *
2167 * Compares files in ABSPATH (non-recursive) against the official core
2168 * checksums list. PHP files not in the core distribution are flagged as
2169 * suspicious (common attack vector: info.php, shell.php, backdoors).
2170 * Non-PHP files not in the known safe list are flagged as extra/additional.
2171 * Dotfiles and known safe files (robots.txt, etc.) are skipped.
2172 *
2173 * @return array Array with 'suspicious' and 'extra' sub-arrays.
2174 */
2175 private function scan_root_files() {
2176 $found = array(
2177 'suspicious' => array(),
2178 'extra' => array(),
2179 );
2180
2181 // Get core checksums to know which root files are legitimate
2182 $checksums = $this->get_core_checksums();
2183 if ( is_wp_error( $checksums ) ) {
2184 return $found;
2185 }
2186
2187 // Build list of known core root files from checksums (only root-level, no directory prefix)
2188 $core_root_files = array();
2189 foreach ( array_keys( $checksums ) as $file ) {
2190 // Only root-level files (no directory separator)
2191 if ( false === strpos( $file, '/' ) ) {
2192 $core_root_files[] = $file;
2193 }
2194 }
2195
2196 // Also add wp-config.php which is not in checksums but is core
2197 $core_root_files[] = 'wp-config.php';
2198
2199 $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
2200
2201 // Scan only direct children of ABSPATH (not recursive)
2202 $root_path = untrailingslashit( ABSPATH );
2203 $handle = opendir( $root_path );
2204
2205 if ( ! $handle ) {
2206 return $found;
2207 }
2208
2209 while ( false !== ( $entry = readdir( $handle ) ) ) {
2210 if ( $this->is_time_exceeded() ) {
2211 break;
2212 }
2213
2214 // Skip . and ..
2215 if ( '.' === $entry || '..' === $entry ) {
2216 continue;
2217 }
2218
2219 // Skip dotfiles (.htaccess, .user.ini, .env, etc.) — handled by firewall protection
2220 if ( 0 === strpos( $entry, '.' ) ) {
2221 continue;
2222 }
2223
2224 $full_path = $root_path . '/' . $entry;
2225
2226 // Skip directories — we only care about files in root
2227 if ( is_dir( $full_path ) ) {
2228 continue;
2229 }
2230
2231 // Skip if this is a known core file
2232 if ( in_array( $entry, $core_root_files, true ) ) {
2233 continue;
2234 }
2235
2236 // Skip excluded paths
2237 if ( $this->is_path_excluded( $full_path ) ) {
2238 continue;
2239 }
2240
2241 // Skip known safe non-PHP root files
2242 if ( in_array( strtolower( $entry ), $this->known_safe_root_files, true ) ) {
2243 continue;
2244 }
2245
2246 $extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) );
2247
2248 if ( in_array( $extension, $php_extensions, true ) ) {
2249 // Check if this is a legacy WordPress core file (removed from newer versions)
2250 if ( in_array( $entry, $this->legacy_core_root_files, true ) ) {
2251 $found['extra'][] = array(
2252 'file' => $entry,
2253 'type' => 'legacy_core',
2254 'reason' => __( 'Legacy WordPress core file, removed in newer versions. Safe to delete.', 'vigilante' ),
2255 );
2256 continue;
2257 }
2258
2259 // Silence-is-golden placeholders dropped here by some setups
2260 // (e.g. WordPress installed in a subdirectory, or third-party tooling).
2261 if ( $this->is_silence_golden_file( $full_path ) ) {
2262 continue;
2263 }
2264
2265 // PHP file not in core = suspicious
2266 $reason = __( 'Non-core PHP file in WordPress root directory', 'vigilante' );
2267
2268 // Scan content for specific patterns
2269 if ( filesize( $full_path ) < 512000 ) {
2270 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2271 $pattern = $this->detect_suspicious_pattern( $content );
2272 if ( $pattern ) {
2273 /* translators: %s: Suspicious pattern found */
2274 $reason = sprintf( __( 'Non-core PHP in root with suspicious code: %s', 'vigilante' ), $pattern );
2275 }
2276 }
2277
2278 $found['suspicious'][] = array(
2279 'file' => $entry,
2280 'type' => 'php_in_root',
2281 'reason' => $reason,
2282 );
2283 } else {
2284 // Non-PHP, non-known-safe file = additional (informational)
2285 $found['extra'][] = array(
2286 'file' => $entry,
2287 'type' => 'extra_root',
2288 'reason' => __( 'Non-core file in WordPress root directory', 'vigilante' ),
2289 );
2290 }
2291 }
2292
2293 closedir( $handle );
2294
2295 return $found;
2296 }
2297
2298 // =========================================================================
2299 // Critical config file baseline monitoring (wp-config.php, .htaccess)
2300 // =========================================================================
2301
2302 /**
2303 * Scan critical root files against stored baseline hashes
2304 *
2305 * Files like wp-config.php and .htaccess have no official WordPress.org
2306 * checksum because their content is unique per installation. We maintain
2307 * our own baseline hash and alert when the file changes outside of
2308 * Vigilante's own modifications.
2309 *
2310 * On the first scan (no baseline stored yet) the baseline is created
2311 * silently — there is nothing to compare against.
2312 *
2313 * @return array Array of modified file entries (same format as core modified).
2314 */
2315 private function scan_critical_root_files() {
2316 // Before reading anything: the scan is the only thing that reaches
2317 // every site of a network on its own, through wp-cron and front-end
2318 // traffic. Hooking the cleanup to admin_init alone left every subsite
2319 // whose dashboard nobody opens with its old copy of wp-config.php,
2320 // credentials included, for as long as nobody visited it.
2321 $this->maybe_redact_stored_baseline();
2322
2323 // And claim the blocks already on disk before anything is compared,
2324 // so the first scan after updating uses the rule that will stay.
2325 $this->maybe_claim_owned_blocks();
2326
2327 $modified = array();
2328 $baseline = $this->get_critical_files_baseline();
2329 $baseline_changed = false;
2330 $root_path = untrailingslashit( ABSPATH );
2331
2332 foreach ( $this->critical_root_files as $filename ) {
2333 $full_path = $root_path . '/' . $filename;
2334
2335 if ( ! file_exists( $full_path ) ) {
2336 continue;
2337 }
2338
2339 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2340 if ( false === $content ) {
2341 continue;
2342 }
2343
2344 $normalized = $this->normalize_critical_file( $filename, $content );
2345 $current_hash = md5( $normalized );
2346
2347 if ( ! isset( $baseline[ $filename ] ) ) {
2348 // First time seeing this file — store baseline silently
2349 $baseline[ $filename ] = array(
2350 'hash' => $current_hash,
2351 'size' => strlen( $content ),
2352 'content' => $this->baseline_content( $filename, $normalized ),
2353 'updated' => time(),
2354 );
2355 $baseline_changed = true;
2356 continue;
2357 }
2358
2359 // Upgrade legacy baseline entries that lack content (pre-diff format)
2360 if ( ! isset( $baseline[ $filename ]['content'] ) && $baseline[ $filename ]['hash'] === $current_hash ) {
2361 $baseline[ $filename ]['content'] = $this->baseline_content( $filename, $normalized );
2362 $baseline_changed = true;
2363 continue;
2364 }
2365
2366 /*
2367 * The file has not changed, but the copy on record is not the copy
2368 * that would be stored today: an entry written before 2.11.2 with
2369 * the credentials in it, or one written before the redaction list
2370 * grew. Rewrite it.
2371 *
2372 * Only when the hash matches, and that condition is the whole
2373 * point: if the file HAD changed, this entry is the evidence of
2374 * the change that the administrator still has to review, and
2375 * rewriting it here would quietly destroy that evidence.
2376 */
2377 if ( $baseline[ $filename ]['hash'] === $current_hash ) {
2378 $expected = $this->baseline_content( $filename, $normalized );
2379
2380 if ( $expected !== $baseline[ $filename ]['content'] ) {
2381 $baseline[ $filename ]['content'] = $expected;
2382 $baseline_changed = true;
2383 }
2384
2385 continue;
2386 }
2387
2388 /*
2389 * Everything from here down is the changed file, and only that: the
2390 * branch above returns on every matching hash, so there is no third
2391 * case and no condition left to test. It used to be wrapped in an
2392 * `if` repeating the opposite comparison, which read as if some
2393 * other path could reach this point. It could not. Flagged by
2394 * @calzbert, and worth the two lines it costs to say so.
2395 *
2396 * Both sides go through the same redaction, or every credential
2397 * line would read as a change nobody made.
2398 */
2399 /*
2400 * Where the shared files do not belong to this site, the lines are
2401 * not computed at all. The diff is shown to whoever can open this
2402 * site's screen, the administrator of a subsite included, and it
2403 * was stored in the options of every subsite with the module on.
2404 * Found by the audit of the admin surface for 2.11.8. The change is
2405 * still reported, with both sizes, and the lines are read on the
2406 * main site, where the change is approved.
2407 */
2408 if ( ! Vigilante_Settings::owns_shared_files() ) {
2409 $diff = self::network_only_diff();
2410 } else {
2411 $baseline_content = $baseline[ $filename ]['content'] ?? '';
2412 $current_content = $this->baseline_content( $filename, $normalized );
2413 $diff = ( '' !== $baseline_content && '' !== $current_content )
2414 ? $this->compute_simple_diff( $baseline_content, $current_content )
2415 : array( 'added' => array(), 'removed' => array(), 'unavailable' => true );
2416
2417 // Say why there are no lines when today's copy could not be
2418 // made safe, which approving does not change: the generic
2419 // message talks about an old baseline. Cross review of 2.11.8.
2420 if ( '' === $current_content && 'wp-config.php' === $filename ) {
2421 $diff['redaction'] = true;
2422 }
2423 }
2424
2425 $modified[] = array(
2426 'file' => $filename,
2427 'type' => 'critical_config',
2428 'expected_hash' => $baseline[ $filename ]['hash'],
2429 'actual_hash' => $current_hash,
2430 'baseline_size' => $baseline[ $filename ]['size'],
2431 'current_size' => strlen( $content ),
2432 'diff' => $diff,
2433 );
2434 }
2435
2436 if ( $baseline_changed ) {
2437 $this->write_baseline( $baseline );
2438 }
2439
2440 return $modified;
2441 }
2442
2443 /**
2444 * Compute a simple line-based diff between two strings
2445 *
2446 * Returns added and removed lines with their original line numbers.
2447 * Order is preserved. Uses a simple "line present in set" approach
2448 * which works well for config files where most lines are unique.
2449 *
2450 * @param string $old Baseline content.
2451 * @param string $new Current content.
2452 * @return array Array with 'added' and 'removed' line entries.
2453 */
2454 private function compute_simple_diff( $old, $new ) {
2455 $old_lines = explode( "\n", $old );
2456 $new_lines = explode( "\n", $new );
2457
2458 // Use hash sets for O(1) lookup. Use array_flip for cheap existence check.
2459 $old_set = array_count_values( $old_lines );
2460 $new_set = array_count_values( $new_lines );
2461
2462 $removed = array();
2463 foreach ( $old_lines as $i => $line ) {
2464 // Line only considered removed if baseline has more occurrences than current
2465 if ( ! isset( $new_set[ $line ] ) || $new_set[ $line ] < ( $old_set[ $line ] ?? 0 ) ) {
2466 $removed[] = array(
2467 'line' => $i + 1,
2468 'content' => $line,
2469 );
2470 // Decrement to handle duplicates correctly
2471 if ( isset( $old_set[ $line ] ) ) {
2472 $old_set[ $line ]--;
2473 }
2474 }
2475 }
2476
2477 // Reset for added detection
2478 $old_set = array_count_values( $old_lines );
2479 $added = array();
2480 foreach ( $new_lines as $i => $line ) {
2481 if ( ! isset( $old_set[ $line ] ) || $old_set[ $line ] < ( $new_set[ $line ] ?? 0 ) ) {
2482 $added[] = array(
2483 'line' => $i + 1,
2484 'content' => $line,
2485 );
2486 if ( isset( $new_set[ $line ] ) ) {
2487 $new_set[ $line ]--;
2488 }
2489 }
2490 }
2491
2492 return array(
2493 'added' => $added,
2494 'removed' => $removed,
2495 'unavailable' => false,
2496 );
2497 }
2498
2499 /**
2500 * Normalize critical file content by removing Vigilante-managed blocks
2501 *
2502 * This ensures that changes made by Vigilante itself (security constants,
2503 * htaccess rules) do not trigger false-positive modification alerts.
2504 * Line endings are normalized to LF to prevent false positives from
2505 * editors that change CRLF/LF.
2506 *
2507 * @param string $filename File name (e.g. 'wp-config.php').
2508 * @param string $content Raw file content.
2509 * @return string Normalized content for hashing.
2510 */
2511 private function normalize_critical_file( $filename, $content, $drop_all_original = false ) {
2512 // Normalize line endings first (CRLF and CR to LF)
2513 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
2514
2515 /*
2516 * Vigilant's own blocks are left out of the hash, so rewriting them is
2517 * not reported as somebody else's change. Until 2.11.5 that covered
2518 * everything between the markers, and every line carrying the
2519 * [VIGILANTE_ORIGINAL] marker, whatever they contained. From 2.11.5 a
2520 * block is left out only if it is exactly a block Vigilant wrote (see
2521 * remember_owned_block()), and a marked line only while uncommenting it
2522 * would still give a harmless define() (see is_vigilant_original_line()).
2523 *
2524 * Until the blocks already on disk have been claimed, the old rule
2525 * applies unchanged. That is what keeps an update from changing the
2526 * hash of a file nobody touched.
2527 */
2528 $claimed = $this->owned_blocks_claimed();
2529
2530 if ( 'wp-config.php' === $filename ) {
2531 // Vigilante constants blocks (current and legacy)
2532 foreach ( $this->wpconfig_markers as $markers ) {
2533 $content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed );
2534 }
2535
2536 // Lines commented out by Vigilante (original constants)
2537 $content = preg_replace_callback(
2538 '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m',
2539 function ( $line ) use ( $claimed, $drop_all_original ) {
2540 // $drop_all_original reproduce la regla anterior a la 2.11.5 (quitar
2541 // toda linea marcada) sobre los bloques de la regla nueva. Solo lo usa
2542 // el re-base de la transicion, para decidir si la unica diferencia con
2543 // el registro aprobado son estas lineas. Ver rebase_original_line_shift().
2544 return ( ! $claimed || $drop_all_original || $this->is_vigilant_original_line( $line[0] ) ) ? '' : $line[0];
2545 },
2546 $content
2547 );
2548 } elseif ( '.htaccess' === $filename ) {
2549 // Vigilante htaccess blocks (firewall + security headers)
2550 foreach ( $this->htaccess_markers as $markers ) {
2551 $content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed );
2552 }
2553 }
2554
2555 // Collapse multiple blank lines into one (blocks removal leaves gaps)
2556 $content = preg_replace( '/\n{3,}/', "\n\n", $content );
2557
2558 return trim( $content );
2559 }
2560
2561 /**
2562 * Leave Vigilant's blocks for one pair of markers out of the content
2563 *
2564 * Before the claim, every block, as it always was. After it, only the blocks
2565 * whose fingerprint was recorded when Vigilant wrote them. A block that does
2566 * not match, edited or planted, stays in the content: it counts in the hash
2567 * and shows up in the diff.
2568 *
2569 * The match runs from marker to marker and the removal also takes the
2570 * whitespace after the block, exactly as before, so a file whose blocks are
2571 * all Vigilant's normalizes to the same text under both rules.
2572 *
2573 * @since 2.11.5
2574 *
2575 * @param string $filename 'wp-config.php' or '.htaccess'.
2576 * @param string $content Content with normalized line endings.
2577 * @param array $markers Start and end marker.
2578 * @param bool $claimed Whether the claim has run.
2579 * @return string
2580 */
2581 private function strip_vigilant_blocks( $filename, $content, $markers, $claimed ) {
2582 $pattern = '/(' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . ')\s*/s';
2583
2584 if ( ! $claimed ) {
2585 return preg_replace( $pattern, '', $content );
2586 }
2587
2588 return preg_replace_callback(
2589 $pattern,
2590 function ( $match ) use ( $filename ) {
2591 return self::is_owned_block( $filename, $match[1] ) ? '' : $match[0];
2592 },
2593 $content
2594 );
2595 }
2596
2597 /**
2598 * Whether a line carrying the original-constant marker is one Vigilant wrote
2599 *
2600 * comment_existing_constants() puts the marker in front of a define() of a
2601 * constant it manages, and uncomment_original_constants() takes it away
2602 * again whenever the constants are applied or removed, so whatever follows
2603 * the marker gets to run some day. The line is left out of the hash only
2604 * when there is nothing but indentation before the marker, nothing after it
2605 * but a harmless define() and at most a line comment, and no PHP tag
2606 * anywhere on it. That keeps it a comment today and harmless once
2607 * uncommented. Anything else counts, and shows up in the diff.
2608 *
2609 * @since 2.11.5
2610 *
2611 * @param string $line One line of wp-config.php.
2612 * @return bool
2613 */
2614 private function is_vigilant_original_line( $line ) {
2615 if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) {
2616 return false;
2617 }
2618
2619 return 1 === preg_match(
2620 '/^[ \t]*' . preg_quote( $this->wpconfig_original_marker, '/' ) . self::harmless_define_pattern() . '[ \t]*(?:(?:\/\/|#(?!\[)).*)?$/',
2621 $line
2622 );
2623 }
2624
2625 /**
2626 * A define() that runs nothing but itself, as a regular expression fragment
2627 *
2628 * The name is one of the constants Vigilant has managed in any version. The
2629 * value is made only of literals (true, false, null, a number, a quoted
2630 * string with nothing to interpolate) and of ABSPATH, WP_CONTENT_DIR and
2631 * __DIR__, which is what a debug log path is usually built from, joined
2632 * with dots. No call, no variable, no backtick, no include.
2633 *
2634 * @since 2.11.5
2635 *
2636 * @return string Pattern without delimiters.
2637 */
2638 private static function harmless_define_pattern() {
2639 $names = 'DISALLOW_FILE_EDIT|DISALLOW_FILE_MODS|FORCE_SSL_ADMIN|FORCE_SSL_LOGIN|WP_DEBUG|WP_DEBUG_LOG|WP_DEBUG_DISPLAY|SCRIPT_DEBUG|DISABLE_WP_CRON'
2640 . '|WP_POST_REVISIONS|AUTOSAVE_INTERVAL|EMPTY_TRASH_DAYS|WP_MEMORY_LIMIT|WP_MAX_MEMORY_LIMIT|WP_AUTO_UPDATE_CORE|CONCATENATE_SCRIPTS';
2641
2642 $value = '(?:(?i:true|false|null)|-?\d+|\'(?:[^\'\\\\]|\\\\.)*\'|"[^"\\\\$]*"|ABSPATH|WP_CONTENT_DIR|__DIR__)';
2643
2644 return 'define\s*\(\s*[\'"](?:' . $names . ')[\'"]\s*,\s*' . $value . '(?:\s*\.\s*' . $value . ')*\s*\)\s*;';
2645 }
2646
2647 /**
2648 * Whether a wp-config.php constants block can only be one Vigilant wrote
2649 *
2650 * Every version of generate_constants() has written the start marker on its
2651 * own line, then comments, blank lines and define() calls, bare or wrapped
2652 * in if ( ! defined() ), then the end marker on its own line. A block made
2653 * only of those lines runs nothing but the defines, whatever version wrote
2654 * it and whatever settings it was written with. One line of anything else,
2655 * or a PHP tag on any line, and the block is not taken.
2656 *
2657 * @since 2.11.5
2658 *
2659 * @param string $block Block from start marker to end marker, inclusive.
2660 * @param array $markers Start and end marker.
2661 * @return bool
2662 */
2663 private static function is_harmless_constants_block( $block, $markers ) {
2664 $lines = explode( "\n", str_replace( array( "\r\n", "\r" ), "\n", (string) $block ) );
2665
2666 if ( count( $lines ) < 2
2667 || rtrim( array_shift( $lines ), " \t" ) !== $markers[0]
2668 || ltrim( array_pop( $lines ), " \t" ) !== $markers[1]
2669 ) {
2670 return false;
2671 }
2672
2673 $define = self::harmless_define_pattern();
2674 $guarded = '/^if\s*\(\s*!\s*defined\s*\(\s*[\'"][A-Z_]+[\'"]\s*\)\s*\)\s*\{\s*' . $define . '\s*\}$/';
2675
2676 foreach ( $lines as $line ) {
2677 $line = trim( $line, " \t" );
2678
2679 if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) {
2680 return false;
2681 }
2682
2683 if ( '' === $line
2684 || 0 === strpos( $line, '//' )
2685 || preg_match( '/^' . $define . '$/', $line )
2686 || preg_match( $guarded, $line )
2687 ) {
2688 continue;
2689 }
2690
2691 return false;
2692 }
2693
2694 return true;
2695 }
2696
2697 /**
2698 * Take ownership of the blocks already on disk, once
2699 *
2700 * Fingerprints are recorded when Vigilant writes a block, which leaves every
2701 * block written before 2.11.5 without one. This records the blocks that can
2702 * be recognised as Vigilant's without having seen them written:
2703 *
2704 * - A .htaccess block, when it is exactly what Vigilant would write today
2705 * with the settings it has, the timestamp and the version apart. After an
2706 * update, maybe_sync_server_files() rewrites those blocks on the next
2707 * request, so the claim waits for it. Unless it has already failed: a
2708 * block Vigilant cannot rewrite is not going to start matching, and
2709 * waiting for it would keep the old rule for good.
2710 * - A wp-config.php constants block, when every line in it is a comment, a
2711 * blank line or a harmless define(). Nothing rewrites that block on an
2712 * update and its format has changed five times, so comparing it with
2713 * today's output would report every site that has not saved those
2714 * settings since. A line that could run anything is never accepted.
2715 *
2716 * A block that is not recognised stays in the hash and is reported as a
2717 * change, so the owner gets to look at it, and the activity log says why.
2718 * Nothing in the stored baseline is rewritten.
2719 *
2720 * Only where the shared files belong, a single site or the main site of a
2721 * network, because the expected blocks come from that site's settings. Until
2722 * it has run, normalize_critical_file() keeps the old rule on every site.
2723 *
2724 * @since 2.11.5
2725 */
2726 public function maybe_claim_owned_blocks() {
2727 if ( $this->owned_blocks_claimed() || ! Vigilante_Settings::owns_shared_files() ) {
2728 return;
2729 }
2730
2731 $sync_due = get_option( 'vigilante_server_files_pending' )
2732 || VIGILANTE_VERSION !== get_option( 'vigilante_server_files_version' );
2733
2734 if ( $sync_due && ! get_option( 'vigilante_server_files_retry_after' ) ) {
2735 return;
2736 }
2737
2738 $root_path = untrailingslashit( ABSPATH );
2739 $expected = null;
2740 $unclaimed = array();
2741
2742 foreach ( $this->critical_root_files as $filename ) {
2743 $full_path = $root_path . '/' . $filename;
2744
2745 if ( ! file_exists( $full_path ) ) {
2746 continue;
2747 }
2748
2749 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2750
2751 if ( false === $content ) {
2752 // Unreadable right now: leave the claim open and try again later.
2753 return;
2754 }
2755
2756 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
2757 $is_config = 'wp-config.php' === $filename;
2758
2759 foreach ( ( $is_config ? $this->wpconfig_markers : $this->htaccess_markers ) as $markers ) {
2760 $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '/s';
2761
2762 if ( ! preg_match_all( $pattern, $content, $found ) ) {
2763 continue;
2764 }
2765
2766 foreach ( $found[0] as $block ) {
2767 if ( $is_config ) {
2768 $ours = self::is_harmless_constants_block( $block, $markers );
2769 } else {
2770 $expected = null === $expected ? $this->expected_htaccess_blocks() : $expected;
2771 $ours = isset( $expected[ $markers[0] ] )
2772 && self::comparable_block( $block ) === self::comparable_block( $expected[ $markers[0] ] );
2773 }
2774
2775 if ( $ours ) {
2776 self::remember_owned_block( $filename, $markers[0], $block, false );
2777 } else {
2778 $unclaimed[ $filename ] = $filename;
2779 }
2780 }
2781 }
2782
2783 // The commented-out originals are judged line by line at scan time.
2784 // Looking at them here only keeps the log entry below complete.
2785 if ( $is_config && preg_match_all( '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m', $content, $marked ) ) {
2786 foreach ( $marked[0] as $line ) {
2787 if ( ! $this->is_vigilant_original_line( $line ) ) {
2788 $unclaimed[ $filename ] = $filename;
2789 }
2790 }
2791 }
2792 }
2793
2794 update_site_option( self::OWNED_BLOCKS_CLAIM_OPTION, self::OWNED_BLOCKS_CLAIMED );
2795
2796 // With the claim in place normalize uses the new rule, so a file nobody
2797 // touched whose only difference is an original line the old rule dropped
2798 // would read as changed. Re-base those, and only those, once.
2799 $this->rebase_original_line_shift();
2800
2801 if ( $unclaimed && $this->activity_log ) {
2802 $this->activity_log->log(
2803 'file',
2804 'critical_file_unrecognized_block',
2805 sprintf(
2806 /* translators: %s: comma-separated file names, such as wp-config.php or .htaccess. */
2807 __( 'Content marked as written by Vigilant in %s does not match what Vigilant writes. From now on it is checked like the rest of the file, so the file integrity scan reports it as a change for you to review.', 'vigilante' ),
2808 implode( ', ', $unclaimed )
2809 ),
2810 array( 'files' => array_values( $unclaimed ) ),
2811 'warning'
2812 );
2813 }
2814 }
2815
2816 /**
2817 * Re-base the critical files whose only change is a newly kept original line
2818 *
2819 * Until 2.11.5 the hash left out every [VIGILANTE_ORIGINAL] line; from 2.11.5
2820 * it keeps the ones whose value is not a plain constant define, which is the
2821 * right thing for the hash but moves it on a file nobody edited: the stored
2822 * baseline was taken under the old rule, and nothing re-bases wp-config.php on
2823 * an update (maybe_sync_server_files() only rewrites the .htaccess). So the
2824 * first scan after updating would report wp-config.php as changed.
2825 *
2826 * This runs once, in the same pass that claims the blocks. For each file it
2827 * re-bases to the new hash only when the baseline still matches the file with
2828 * every original line dropped, which means the blocks are exactly the approved
2829 * ones and the sole difference is those lines, the user's own commented-out
2830 * defines. A block that was edited or planted does not match with the lines
2831 * dropped, so it is left to be reported: this closes the false positive
2832 * without adopting anything that was hidden before.
2833 *
2834 * @since 2.11.5
2835 */
2836 private function rebase_original_line_shift() {
2837 $baseline = $this->get_critical_files_baseline();
2838 $root = untrailingslashit( ABSPATH );
2839 $changed = false;
2840
2841 foreach ( $this->critical_root_files as $filename ) {
2842 $full_path = $root . '/' . $filename;
2843
2844 if ( ! file_exists( $full_path ) || empty( $baseline[ $filename ]['hash'] ) ) {
2845 continue;
2846 }
2847
2848 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2849
2850 if ( false === $content ) {
2851 continue;
2852 }
2853
2854 $current = md5( $this->normalize_critical_file( $filename, $content ) );
2855
2856 // Already in step, or a real change to something other than the
2857 // original lines: nothing to re-base here.
2858 if ( $baseline[ $filename ]['hash'] === $current
2859 || $baseline[ $filename ]['hash'] !== md5( $this->normalize_critical_file( $filename, $content, true ) )
2860 ) {
2861 continue;
2862 }
2863
2864 $normalized = $this->normalize_critical_file( $filename, $content );
2865 $baseline[ $filename ]['hash'] = $current;
2866 $baseline[ $filename ]['size'] = strlen( $content );
2867 $baseline[ $filename ]['content'] = $this->baseline_content( $filename, $normalized );
2868 $baseline[ $filename ]['updated'] = time();
2869 $changed = true;
2870 }
2871
2872 if ( $changed ) {
2873 $this->write_baseline( $baseline );
2874 }
2875 }
2876
2877 /**
2878 * The .htaccess blocks Vigilant would write today, keyed by start marker
2879 *
2880 * @since 2.11.5
2881 *
2882 * @return array
2883 */
2884 private function expected_htaccess_blocks() {
2885 $settings = $this->settings ? $this->settings : new Vigilante_Settings();
2886
2887 $classes = array(
2888 'Vigilante_Htaccess_Protection' => 'class-htaccess-protection.php',
2889 'Vigilante_Security_Headers' => 'class-security-headers.php',
2890 );
2891
2892 foreach ( $classes as $class => $file ) {
2893 if ( ! class_exists( $class ) ) {
2894 require_once VIGILANTE_INCLUDES_DIR . $file;
2895 }
2896 }
2897
2898 $headers = new Vigilante_Security_Headers( $settings );
2899
2900 return array(
2901 Vigilante_Htaccess_Protection::MARKER_START => ( new Vigilante_Htaccess_Protection( $settings ) )->generate_rules(),
2902 Vigilante_Security_Headers::MARKER_START => Vigilante_Security_Headers::MARKER_START . "\n" . $headers->generate_rules_content() . "\n" . Vigilante_Security_Headers::MARKER_END,
2903 );
2904 }
2905
2906 /**
2907 * A .htaccess block with the parts that change on every write evened out
2908 *
2909 * Two blocks Vigilant wrote with the same settings differ only in the time
2910 * they were generated and, across an update, in the version the firewall
2911 * block names. Everything else has to be identical for the claim to take
2912 * the block.
2913 *
2914 * @since 2.11.5
2915 *
2916 * @param string $block Block from start marker to end marker, inclusive.
2917 * @return string
2918 */
2919 private static function comparable_block( $block ) {
2920 $block = str_replace( array( "\r\n", "\r" ), "\n", (string) $block );
2921 $block = preg_replace( '/^# Generated: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/m', '# Generated:', $block );
2922 $block = preg_replace( '/^# Vigilante for WordPress - Firewall v[0-9][0-9A-Za-z.\-]*$/m', '# Vigilante for WordPress - Firewall v', $block );
2923
2924 return rtrim( $block, "\n" );
2925 }
2926
2927 /**
2928 * Get stored baseline hashes for critical files
2929 *
2930 * @return array Associative array keyed by filename.
2931 */
2932 public function get_critical_files_baseline() {
2933 return $this->read_baseline();
2934 }
2935
2936 /**
2937 * Update baseline hash for a single critical file
2938 *
2939 * Called by wp-config and htaccess writers after Vigilante modifies
2940 * the file, so the next scan does not flag the change as suspicious.
2941 *
2942 * @param string $filename File name relative to ABSPATH (e.g. 'wp-config.php').
2943 * @return bool True on success.
2944 */
2945 public function update_critical_file_baseline( $filename ) {
2946 $full_path = untrailingslashit( ABSPATH ) . '/' . $filename;
2947
2948 if ( ! file_exists( $full_path ) ) {
2949 return false;
2950 }
2951
2952 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2953 if ( false === $content ) {
2954 return false;
2955 }
2956
2957 $normalized = $this->normalize_critical_file( $filename, $content );
2958
2959 $baseline = $this->get_critical_files_baseline();
2960
2961 /*
2962 * No guard here, and there was one for a few hours during 2.11.4 that
2963 * had to come out. It refused to rewrite the record when the stored hash
2964 * no longer matched the file, meant to stop a write of ours from
2965 * approving somebody else's pending edit. Two things were wrong with it,
2966 * both measured on 10 sep 2026 by a third cross review:
2967 *
2968 * - This is also the Approve button (Vigilante_Admin_Ajax::
2969 * ajax_approve_critical_file). A moved hash is exactly the state in
2970 * which Approve is pressed, so the guard made Approve fail every time
2971 * and the warning could never be closed.
2972 * - Its premise, "our own write cannot move the normalized hash", holds
2973 * for the block and not for the rest of what the writers do.
2974 * comment_existing_constants() turns a define() into a
2975 * [VIGILANTE_ORIGINAL] line that normalize_critical_file() leaves as
2976 * an empty line, and remove_old_rules() deletes legacy .htaccess blocks
2977 * that normalize_critical_file() does not know. Both move the hash, so
2978 * the guard would have raised a false "file modified" after Vigilant's
2979 * own work, on sites that updated.
2980 *
2981 * The real fix is to know what the hash was before WE touched the file:
2982 * the writers capture it and pass it along vigilante_critical_file_
2983 * written, and this compares against that instead of against the
2984 * record. Until then this behaves as it always has, which does mean a
2985 * write of ours can adopt a third-party edit that was pending review.
2986 * That is pre-existing, and written down in the roadmap.
2987 */
2988 $baseline[ $filename ] = array(
2989 'hash' => md5( $normalized ),
2990 'size' => strlen( $content ),
2991 'content' => $this->baseline_content( $filename, $normalized ),
2992 'updated' => time(),
2993 );
2994
2995 return $this->write_baseline( $baseline );
2996 }
2997
2998 /**
2999 * Regenerate baseline for all critical files
3000 *
3001 * Used by the admin UI button and the 1.14.0 migration.
3002 *
3003 * @return array Updated baseline data.
3004 */
3005 public function regenerate_all_baselines() {
3006 $baseline = array();
3007 $root_path = untrailingslashit( ABSPATH );
3008
3009 foreach ( $this->critical_root_files as $filename ) {
3010 $full_path = $root_path . '/' . $filename;
3011
3012 if ( ! file_exists( $full_path ) ) {
3013 continue;
3014 }
3015
3016 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
3017 if ( false === $content ) {
3018 continue;
3019 }
3020
3021 $normalized = $this->normalize_critical_file( $filename, $content );
3022 $baseline[ $filename ] = array(
3023 'hash' => md5( $normalized ),
3024 'size' => strlen( $content ),
3025 'content' => $this->baseline_content( $filename, $normalized ),
3026 'updated' => time(),
3027 );
3028 }
3029
3030 $this->write_baseline( $baseline );
3031
3032 return $baseline;
3033 }
3034
3035 /**
3036 * Get core checksums from WordPress.org API
3037 *
3038 * @param bool $force_refresh Skip the cached copy and ask wp.org again.
3039 * @return array|WP_Error Checksums or error.
3040 */
3041 private function get_core_checksums( $force_refresh = false ) {
3042 $locale = get_locale();
3043 $version = $this->wp_version;
3044
3045 // Check cache first
3046 $cache_key = 'vigilante_core_checksums_' . md5( $version . $locale );
3047
3048 if ( $force_refresh ) {
3049 delete_transient( $cache_key );
3050 }
3051
3052 $cached = get_transient( $cache_key );
3053 if ( false !== $cached ) {
3054 return $cached;
3055 }
3056
3057 // Fetch from WordPress.org
3058 $url = sprintf(
3059 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s',
3060 $version,
3061 $locale
3062 );
3063
3064 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
3065
3066 if ( is_wp_error( $response ) ) {
3067 return $response;
3068 }
3069
3070 $body = json_decode( wp_remote_retrieve_body( $response ), true );
3071
3072 if ( empty( $body['checksums'] ) ) {
3073 return new WP_Error( 'no_checksums', __( 'Could not retrieve WordPress core checksums', 'vigilante' ) );
3074 }
3075
3076 $checksums = $body['checksums'];
3077
3078 // Handle nested format: checksums keyed under version string (WP 6.9+)
3079 if ( isset( $checksums[ $version ] ) && is_array( $checksums[ $version ] ) ) {
3080 $checksums = $checksums[ $version ];
3081 }
3082
3083 // Cache for 24 hours
3084 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
3085
3086 return $checksums;
3087 }
3088
3089 /**
3090 * Scan plugins for modifications
3091 *
3092 * @return array Scan results.
3093 */
3094 private function scan_plugins() {
3095 $results = array(
3096 'scanned' => 0,
3097 'ok' => 0,
3098 'modified' => array(),
3099 'suspicious' => array(),
3100 'extra' => array(),
3101 'errors' => array(),
3102 );
3103
3104 // Get all installed plugins
3105 if ( ! function_exists( 'get_plugins' ) ) {
3106 require_once ABSPATH . 'wp-admin/includes/plugin.php';
3107 }
3108
3109 $plugins = get_plugins();
3110
3111 foreach ( $plugins as $plugin_file => $plugin_data ) {
3112 // Check time limit
3113 if ( $this->is_time_exceeded() ) {
3114 break;
3115 }
3116
3117 $plugin_slug = dirname( $plugin_file );
3118
3119 // Skip single-file plugins
3120 if ( '.' === $plugin_slug ) {
3121 continue;
3122 }
3123
3124 // Skip slugs in their post-update grace window: wp.org may still be
3125 // publishing the new version's checksums, so a scheduled scan here
3126 // would raise benign "modified/extra" noise. The dedicated post-update
3127 // verifier (vigilante_fi_postupdate_verify) handles these instead.
3128 if ( $this->in_post_update_grace( 'plugin', $plugin_slug ) ) {
3129 continue;
3130 }
3131
3132 // Get checksums from WordPress.org
3133 $version = $plugin_data['Version'] ?? '';
3134 $checksums = $this->get_plugin_checksums( $plugin_slug, $version );
3135
3136 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
3137
3138 $plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug;
3139
3140 // Check known files against checksums (only if available)
3141 if ( $has_checksums ) {
3142 foreach ( $checksums as $file => $expected_hash ) {
3143 // Check time limit inside inner loop too
3144 if ( $this->is_time_exceeded() ) {
3145 break 2; // Break both loops
3146 }
3147
3148 $file_path = $plugin_dir . '/' . $file;
3149
3150 // Skip excluded paths
3151 if ( $this->is_path_excluded( $file_path ) ) {
3152 continue;
3153 }
3154
3155 // Skip excluded extensions
3156 if ( $this->is_extension_excluded( $file_path ) ) {
3157 continue;
3158 }
3159
3160 // Skip known false positives (e.g. readme.txt, readme.md)
3161 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
3162 continue;
3163 }
3164
3165 $results['scanned']++;
3166
3167 if ( ! file_exists( $file_path ) ) {
3168 continue; // Some files might not be installed
3169 }
3170
3171 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
3172 $results['modified'][] = array(
3173 'file' => 'plugins/' . $plugin_slug . '/' . $file,
3174 'type' => 'plugin',
3175 'plugin' => $plugin_data['Name'],
3176 'expected_hash' => $this->expected_hash_label( $expected_hash ),
3177 'actual_hash' => md5_file( $file_path ),
3178 );
3179 } else {
3180 $results['ok']++;
3181 }
3182 }
3183 } // end if $has_checksums
3184
3185 // Detect extra/suspicious files
3186 // With checksums: finds files not in the original distribution
3187 // Without checksums: scans ALL plugin files but only flags suspicious patterns
3188 if ( ! $this->is_time_exceeded() ) {
3189 $known_files = $has_checksums ? $checksums : array();
3190 $suspicious_only = ! $has_checksums; // Without checksums, only report files with suspicious code
3191 $extra_results = $this->detect_extra_files( $plugin_dir, $known_files, 'plugin', $plugin_data['Name'], $suspicious_only );
3192 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
3193 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
3194 }
3195 }
3196
3197 return $results;
3198 }
3199
3200 /**
3201 * Whether a file on disk matches a hash WordPress.org publishes for it.
3202 *
3203 * The wp.org checksums JSON gives, per file, an md5 (and usually a sha256)
3204 * that may be a single string OR an array of strings: a file whose content
3205 * differs across the re-tagged zips that one checksums file covers (e.g. a
3206 * release that shares its checksums file with a beta) gets every valid hash
3207 * listed as an array. The old `$actual !== $expected` comparison evaluated a
3208 * 32-char string against an array as unequal unconditionally, so those files
3209 * were always reported as "modified" even when the on-disk hash was one of
3210 * the published ones. Match against membership, and accept either md5 or
3211 * sha256, so the comparison is correct and strictly stronger than md5-only.
3212 *
3213 * Robust to both shapes: the new record array( 'md5' => ..., 'sha256' => ... )
3214 * and a legacy cached value (a bare md5 string or array), so a transient
3215 * cached by an older version still compares correctly until it expires.
3216 *
3217 * @param string $file_path Absolute path to the file on disk.
3218 * @param array|string $expected Record array, or a legacy md5 string|array.
3219 * @return bool True when the file matches a published hash.
3220 */
3221 private function hash_matches_published( $file_path, $expected ) {
3222 $md5 = $expected;
3223 $sha = null;
3224 if ( is_array( $expected ) && ( array_key_exists( 'md5', $expected ) || array_key_exists( 'sha256', $expected ) ) ) {
3225 $md5 = isset( $expected['md5'] ) ? $expected['md5'] : null;
3226 $sha = isset( $expected['sha256'] ) ? $expected['sha256'] : null;
3227 }
3228
3229 if ( null !== $md5 && in_array( md5_file( $file_path ), (array) $md5, true ) ) {
3230 return true;
3231 }
3232 if ( ! empty( $sha ) && in_array( hash_file( 'sha256', $file_path ), (array) $sha, true ) ) {
3233 return true;
3234 }
3235
3236 // Fallback: some hosts and deploy pipelines rewrite text files on disk
3237 // (prepend a UTF-8 BOM, or convert LF line endings to CRLF) without
3238 // changing a single line of code. That alters the raw bytes, so the
3239 // md5/sha256 stops matching WordPress.org even though the file is
3240 // intact, which surfaced as false "modified file" alerts. Retry the
3241 // comparison against a normalized copy (BOM stripped, CRLF/CR collapsed
3242 // to LF) for text files only, so a genuine code change is still caught.
3243 if ( is_string( $file_path ) && '' !== $file_path && $this->is_text_file( $file_path ) && is_readable( $file_path ) ) {
3244 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read for hashing, not remote.
3245 $content = file_get_contents( $file_path );
3246 if ( false !== $content ) {
3247 if ( "\xEF\xBB\xBF" === substr( $content, 0, 3 ) ) {
3248 $content = substr( $content, 3 );
3249 }
3250 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
3251 if ( null !== $md5 && in_array( md5( $content ), (array) $md5, true ) ) {
3252 return true;
3253 }
3254 if ( ! empty( $sha ) && in_array( hash( 'sha256', $content ), (array) $sha, true ) ) {
3255 return true;
3256 }
3257 }
3258 }
3259
3260 return false;
3261 }
3262
3263 /**
3264 * Human-readable expected-hash value for a modified-file result record.
3265 *
3266 * The published md5 may be a string or an array; flatten it for storage.
3267 *
3268 * @param array|string $expected Record array or legacy md5 string|array.
3269 * @return string
3270 */
3271 private function expected_hash_label( $expected ) {
3272 $md5 = ( is_array( $expected ) && array_key_exists( 'md5', $expected ) ) ? $expected['md5'] : $expected;
3273 if ( is_array( $md5 ) ) {
3274 return implode( ', ', array_map( 'strval', $md5 ) );
3275 }
3276 return (string) $md5;
3277 }
3278
3279 /**
3280 * Whether a plugin/theme slug is inside its post-update grace window.
3281 *
3282 * Set by on_upgrade_complete() right after WordPress finishes updating a
3283 * plugin or theme. During the window the checksum cache is bypassed (so a
3284 * stale manifest cached during wp.org's propagation lag is never reused) and
3285 * the scheduled scan skips the slug (the dedicated post-update verifier
3286 * handles it instead), which is what stops the "files don't match
3287 * WordPress.org" false positives right after an update.
3288 *
3289 * @param string $type 'plugin' or 'theme'.
3290 * @param string $slug Slug.
3291 * @return bool
3292 */
3293 private function in_post_update_grace( $type, $slug ) {
3294 return (bool) get_transient( 'vigilante_fi_grace_' . $type . '_' . md5( $slug ) );
3295 }
3296
3297 /**
3298 * Get plugin checksums from WordPress.org
3299 *
3300 * @param string $slug Plugin slug.
3301 * @param string $version Plugin version.
3302 * @return array|WP_Error|string
3303 */
3304 private function get_plugin_checksums( $slug, $version ) {
3305 $cache_key = 'vigilante_plugin_checksums_' . md5( $slug . $version );
3306
3307 // During the post-update grace window, bypass the cache entirely so a
3308 // manifest cached while wp.org was still propagating the new version's
3309 // checksums can never be reused. Fetch fresh and do not write it back.
3310 $grace = $this->in_post_update_grace( 'plugin', $slug );
3311
3312 if ( ! $grace ) {
3313 $cached = get_transient( $cache_key );
3314 if ( false !== $cached ) {
3315 return $cached;
3316 }
3317 }
3318
3319 $url = sprintf(
3320 'https://downloads.wordpress.org/plugin-checksums/%s/%s.json',
3321 $slug,
3322 $version
3323 );
3324
3325 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
3326
3327 if ( is_wp_error( $response ) ) {
3328 return $response;
3329 }
3330
3331 $status = wp_remote_retrieve_response_code( $response );
3332 if ( 200 !== $status ) {
3333 // Cache "not found" to avoid repeated requests (never during grace).
3334 if ( ! $grace ) {
3335 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
3336 }
3337 return 'not_found';
3338 }
3339
3340 $body = json_decode( wp_remote_retrieve_body( $response ), true );
3341
3342 if ( empty( $body['files'] ) ) {
3343 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
3344 }
3345
3346 // Store the full per-file record (md5 + sha256). Either value can be a
3347 // string or an array of strings; hash_matches_published() handles both.
3348 $checksums = array();
3349 foreach ( $body['files'] as $file => $data ) {
3350 $checksums[ $file ] = array(
3351 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
3352 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
3353 );
3354 }
3355
3356 // Cache for 24 hours (never during grace, to avoid persisting a manifest
3357 // wp.org may still be regenerating).
3358 if ( ! $grace ) {
3359 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
3360 }
3361
3362 return $checksums;
3363 }
3364
3365 /**
3366 * Scan themes for modifications
3367 *
3368 * @return array Scan results.
3369 */
3370 private function scan_themes() {
3371 $results = array(
3372 'scanned' => 0,
3373 'ok' => 0,
3374 'modified' => array(),
3375 'suspicious' => array(),
3376 'extra' => array(),
3377 'errors' => array(),
3378 );
3379
3380 $themes = wp_get_themes();
3381
3382 foreach ( $themes as $theme_slug => $theme ) {
3383 // Check time limit
3384 if ( $this->is_time_exceeded() ) {
3385 break;
3386 }
3387
3388 // Skip slugs in their post-update grace window (see scan_plugins()).
3389 if ( $this->in_post_update_grace( 'theme', $theme_slug ) ) {
3390 continue;
3391 }
3392
3393 $version = $theme->get( 'Version' );
3394 $checksums = $this->get_theme_checksums( $theme_slug, $version );
3395
3396 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
3397
3398 $theme_dir = $theme->get_stylesheet_directory();
3399
3400 // Check known files against checksums (only if available)
3401 if ( $has_checksums ) {
3402 foreach ( $checksums as $file => $expected_hash ) {
3403 // Check time limit inside inner loop too
3404 if ( $this->is_time_exceeded() ) {
3405 break 2; // Break both loops
3406 }
3407
3408 $file_path = $theme_dir . '/' . $file;
3409
3410 // Skip excluded paths
3411 if ( $this->is_path_excluded( $file_path ) ) {
3412 continue;
3413 }
3414
3415 // Skip excluded extensions
3416 if ( $this->is_extension_excluded( $file_path ) ) {
3417 continue;
3418 }
3419
3420 // Skip known false positives (e.g. readme.txt, readme.md)
3421 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
3422 continue;
3423 }
3424
3425 $results['scanned']++;
3426
3427 if ( ! file_exists( $file_path ) ) {
3428 continue;
3429 }
3430
3431 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
3432 $results['modified'][] = array(
3433 'file' => 'themes/' . $theme_slug . '/' . $file,
3434 'type' => 'theme',
3435 'theme' => $theme->get( 'Name' ),
3436 'expected_hash' => $this->expected_hash_label( $expected_hash ),
3437 'actual_hash' => md5_file( $file_path ),
3438 );
3439 } else {
3440 $results['ok']++;
3441 }
3442 }
3443 } // end if $has_checksums
3444
3445 // Detect extra/suspicious files
3446 if ( ! $this->is_time_exceeded() ) {
3447 $known_files = $has_checksums ? $checksums : array();
3448 $suspicious_only = ! $has_checksums;
3449 $extra_results = $this->detect_extra_files( $theme_dir, $known_files, 'theme', $theme->get( 'Name' ), $suspicious_only );
3450 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
3451 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
3452 }
3453 }
3454
3455 return $results;
3456 }
3457
3458 /**
3459 * Get theme checksums from WordPress.org
3460 *
3461 * @param string $slug Theme slug.
3462 * @param string $version Theme version.
3463 * @return array|WP_Error
3464 */
3465 private function get_theme_checksums( $slug, $version ) {
3466 $cache_key = 'vigilante_theme_checksums_' . md5( $slug . $version );
3467
3468 // Bypass the cache during the post-update grace window (see plugin path).
3469 $grace = $this->in_post_update_grace( 'theme', $slug );
3470
3471 if ( ! $grace ) {
3472 $cached = get_transient( $cache_key );
3473 if ( false !== $cached ) {
3474 return $cached;
3475 }
3476 }
3477
3478 $url = sprintf(
3479 'https://downloads.wordpress.org/theme-checksums/%s/%s.json',
3480 $slug,
3481 $version
3482 );
3483
3484 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
3485
3486 if ( is_wp_error( $response ) ) {
3487 return $response;
3488 }
3489
3490 $status = wp_remote_retrieve_response_code( $response );
3491 if ( 200 !== $status ) {
3492 // Cache "not found" to avoid repeated requests (never during grace).
3493 if ( ! $grace ) {
3494 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
3495 }
3496 return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) );
3497 }
3498
3499 $body = json_decode( wp_remote_retrieve_body( $response ), true );
3500
3501 if ( empty( $body['files'] ) ) {
3502 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
3503 }
3504
3505 // Store the full per-file record (md5 + sha256), either of which may be a
3506 // string or an array; hash_matches_published() handles both shapes.
3507 $checksums = array();
3508 foreach ( $body['files'] as $file => $data ) {
3509 $checksums[ $file ] = array(
3510 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
3511 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
3512 );
3513 }
3514
3515 // Cache for 24 hours (never during grace).
3516 if ( ! $grace ) {
3517 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
3518 }
3519
3520 return $checksums;
3521 }
3522
3523 /**
3524 * Scan uploads directory for suspicious files
3525 *
3526 * @return array Array with 'suspicious' and 'extra' sub-arrays.
3527 */
3528 private function scan_uploads() {
3529 $found = array(
3530 'suspicious' => array(),
3531 'extra' => array(),
3532 );
3533 $upload_dir = wp_upload_dir();
3534 $base_dir = $upload_dir['basedir'];
3535 $max_files = 10000; // Increased limit for thorough scanning
3536 $files_checked = 0;
3537
3538 // Executable extensions that should never be in uploads
3539 $dangerous_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
3540
3541 if ( ! is_dir( $base_dir ) ) {
3542 return $found;
3543 }
3544
3545 try {
3546 $iterator = new RecursiveIteratorIterator(
3547 new RecursiveDirectoryIterator( $base_dir, RecursiveDirectoryIterator::SKIP_DOTS ),
3548 RecursiveIteratorIterator::LEAVES_ONLY
3549 );
3550
3551 foreach ( $iterator as $file ) {
3552 // Check global time limit
3553 if ( $this->is_time_exceeded() ) {
3554 break;
3555 }
3556
3557 // Check file limit
3558 $files_checked++;
3559 if ( $files_checked > $max_files ) {
3560 break;
3561 }
3562
3563 $file_path = $file->getPathname();
3564 $basename = basename( $file_path );
3565 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
3566 $relative = str_replace( ABSPATH, '', $file_path );
3567
3568 // Skip excluded paths
3569 if ( $this->is_path_excluded( $file_path ) ) {
3570 continue;
3571 }
3572
3573 // 1. Check for PHP files in uploads (most important security check)
3574 if ( in_array( $extension, $dangerous_extensions, true ) ) {
3575 // Silence-is-golden placeholders are dropped by WordPress and many
3576 // plugins into upload subfolders to block directory listings.
3577 // Whitelist by content so an attacker can't bypass the rule with
3578 // a payload named index.php.
3579 if ( $this->is_silence_golden_file( $file_path ) ) {
3580 continue;
3581 }
3582
3583 $reason = __( 'PHP file found in uploads directory', 'vigilante' );
3584
3585 // Scan content for specific suspicious patterns
3586 if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
3587 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
3588 $pattern = $this->detect_suspicious_pattern( $content );
3589 if ( $pattern ) {
3590 /* translators: %s: Suspicious pattern found */
3591 $reason = sprintf( __( 'PHP in uploads with suspicious code: %s', 'vigilante' ), $pattern );
3592 }
3593 }
3594
3595 $found['suspicious'][] = array(
3596 'file' => $relative,
3597 'type' => 'php_in_uploads',
3598 'reason' => $reason,
3599 );
3600 continue;
3601 }
3602
3603 // 2. Check for double extensions (image.php.jpg, file.phtml.png)
3604 if ( preg_match( '/\.(' . implode( '|', $dangerous_extensions ) . ')\.[a-z]{2,4}$/i', $basename ) ) {
3605 $found['suspicious'][] = array(
3606 'file' => $relative,
3607 'type' => 'double_extension',
3608 'reason' => __( 'Double extension detected (possible disguised executable)', 'vigilante' ),
3609 );
3610 continue;
3611 }
3612
3613 // 3. Check for .htaccess files in uploads
3614 // Read content to classify: dangerous rules = suspicious, protective rules = extra
3615 if ( '.htaccess' === $basename ) {
3616 $htaccess_result = $this->classify_htaccess_in_uploads( $file_path, $relative );
3617 $found[ $htaccess_result['category'] ][] = $htaccess_result['item'];
3618 }
3619 }
3620 } catch ( Exception $e ) {
3621 // Ignore iterator errors
3622 }
3623
3624 return $found;
3625 }
3626
3627 /**
3628 * Classify a .htaccess file found in uploads directory
3629 *
3630 * Reads the file content to determine if it contains dangerous rules
3631 * (enabling PHP execution, rewriting to executables) or protective rules
3632 * (deny access, disable indexes). Dangerous = suspicious, protective = extra.
3633 *
3634 * @param string $file_path Absolute file path.
3635 * @param string $relative Relative file path for display.
3636 * @return array Array with 'category' ('suspicious' or 'extra') and 'item' data.
3637 */
3638 private function classify_htaccess_in_uploads( $file_path, $relative ) {
3639 $content = '';
3640
3641 if ( filesize( $file_path ) < 65536 ) { // Only read files < 64KB
3642 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
3643 }
3644
3645 // If we can't read it or it's empty, treat as suspicious (unknown)
3646 if ( empty( trim( $content ) ) ) {
3647 return array(
3648 'category' => 'suspicious',
3649 'item' => array(
3650 'file' => $relative,
3651 'type' => 'htaccess_in_uploads',
3652 'reason' => __( '.htaccess file in uploads directory (empty or unreadable)', 'vigilante' ),
3653 ),
3654 );
3655 }
3656
3657 // Dangerous patterns: rules that enable code execution or rewrite to executables
3658 $dangerous_patterns = array(
3659 '/AddHandler\s+.*(php|cgi|pl|py)/i' => 'AddHandler enabling script execution',
3660 '/AddType\s+application\/x-httpd-php/i' => 'AddType enabling PHP execution',
3661 '/SetHandler\s+.*(php|cgi)/i' => 'SetHandler enabling script execution',
3662 '/php_flag\s+engine\s+on/i' => 'PHP engine enabled',
3663 '/php_admin_flag\s+engine\s+on/i' => 'PHP admin engine enabled',
3664 '/RewriteRule\s+.*\.(php|phtml|phar)/i' => 'Rewrite rule targeting PHP files',
3665 '/auto_prepend_file/i' => 'auto_prepend_file directive',
3666 '/auto_append_file/i' => 'auto_append_file directive',
3667 );
3668
3669 foreach ( $dangerous_patterns as $pattern => $label ) {
3670 if ( preg_match( $pattern, $content ) ) {
3671 return array(
3672 'category' => 'suspicious',
3673 'item' => array(
3674 'file' => $relative,
3675 'type' => 'htaccess_in_uploads',
3676 /* translators: %s: Dangerous rule description */
3677 'reason' => sprintf( __( '.htaccess with dangerous rule: %s', 'vigilante' ), $label ),
3678 ),
3679 );
3680 }
3681 }
3682
3683 // Identify what protective/benign rules it contains for informational display
3684 $found_rules = array();
3685
3686 $benign_patterns = array(
3687 '/Deny\s+from\s+all/i' => 'Deny from all',
3688 '/Require\s+all\s+denied/i' => 'Require all denied',
3689 '/Options\s+.*-Indexes/i' => 'Options -Indexes',
3690 '/Header\s+set/i' => 'Header rules',
3691 '/ExpiresActive/i' => 'Expires/cache rules',
3692 '/RewriteEngine/i' => 'Rewrite rules',
3693 '/FilesMatch/i' => 'FilesMatch rules',
3694 '/ForceType\s+application\/octet/i' => 'ForceType (force download)',
3695 );
3696
3697 foreach ( $benign_patterns as $pattern => $label ) {
3698 if ( preg_match( $pattern, $content ) ) {
3699 $found_rules[] = $label;
3700 }
3701 }
3702
3703 $rules_summary = ! empty( $found_rules )
3704 ? implode( ', ', $found_rules )
3705 : __( 'Custom rules', 'vigilante' );
3706
3707 return array(
3708 'category' => 'extra',
3709 'item' => array(
3710 'file' => $relative,
3711 'type' => 'htaccess_in_uploads',
3712 /* translators: %s: Summary of rules found in the .htaccess file */
3713 'reason' => sprintf( __( '.htaccess in uploads (likely from plugin). Contains: %s', 'vigilante' ), $rules_summary ),
3714 ),
3715 );
3716 }
3717
3718 /**
3719 * Check content for suspicious patterns
3720 *
3721 * @param string $content File content.
3722 * @return bool
3723 */
3724 private function has_suspicious_content( $content ) {
3725 return (bool) $this->detect_suspicious_pattern( $content );
3726 }
3727
3728 /**
3729 * Detect specific suspicious pattern in file content
3730 *
3731 * Two detection levels:
3732 * - Standard (strict=false): for uploads where ANY PHP is already suspicious.
3733 * Single-function matches like dangerous functions, superglobals are enough.
3734 * - Strict (strict=true): for plugins/themes without checksums where PHP is expected.
3735 * Only flags clear obfuscation combos to avoid false positives on legitimate code.
3736 *
3737 * Patterns are loaded from an external JSON file (scan-patterns.json)
3738 * with base64-encoded needles to prevent WAF/antimalware false positives
3739 * on the scanner file itself.
3740 *
3741 * @param string $content File content.
3742 * @param bool $strict Use strict mode (fewer, higher-confidence patterns).
3743 * @return string|false The pattern found, or false.
3744 */
3745 private function detect_suspicious_pattern( $content, $strict = false ) {
3746
3747 if ( $strict ) {
3748 return $this->detect_strict_suspicious_pattern( $content );
3749 }
3750
3751 $patterns_data = $this->load_scan_patterns();
3752 if ( empty( $patterns_data['standard_patterns'] ) ) {
3753 return false;
3754 }
3755
3756 // Standard mode: broad detection for uploads and known-extra files
3757 foreach ( $patterns_data['standard_patterns'] as $encoded_needle => $label ) {
3758 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding pattern definitions, not user input.
3759 $needle = base64_decode( $encoded_needle );
3760 if ( $this->needle_present( $content, $needle ) ) {
3761 return $label;
3762 }
3763 }
3764
3765 // Check for preg_replace with /e modifier (code execution)
3766 if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
3767 return 'preg_replace /e modifier';
3768 }
3769
3770 // Check for long hex-encoded strings (obfuscated payloads)
3771 if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
3772 return 'hex-encoded string';
3773 }
3774
3775 // Check for heavily concatenated chr() calls (char-by-char obfuscation)
3776 if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
3777 return 'chr() concatenation obfuscation';
3778 }
3779
3780 return false;
3781 }
3782
3783 /**
3784 * Strict suspicious pattern detection for plugins/themes without checksums
3785 *
3786 * Only flags high-confidence obfuscation combos that are almost certainly malware.
3787 * Individual functions are normal in plugins and are not flagged.
3788 *
3789 * @param string $content File content.
3790 * @return string|false The pattern found, or false.
3791 */
3792 private function detect_strict_suspicious_pattern( $content ) {
3793
3794 $patterns_data = $this->load_scan_patterns();
3795 if ( empty( $patterns_data['strict_fragments'] ) ) {
3796 return false;
3797 }
3798
3799 // Decode fragment names from JSON
3800 $fragments = array();
3801 foreach ( $patterns_data['strict_fragments'] as $key => $encoded ) {
3802 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
3803 $fragments[ $key ] = base64_decode( $encoded );
3804 }
3805
3806 $ev = $fragments['ev'] ?? '';
3807 $b6 = $fragments['b6'] ?? '';
3808 $gz = $fragments['gz'] ?? '';
3809 $gu = $fragments['gu'] ?? '';
3810 $sr = $fragments['sr'] ?? '';
3811 $hb = $fragments['hb'] ?? '';
3812 $as = $fragments['as'] ?? '';
3813 $ss = $fragments['ss'] ?? '';
3814 $cf = $fragments['cf'] ?? '';
3815
3816 // Obfuscation combos: dangerous function wrapping decoded content
3817 $obfuscation_combos = array(
3818 '/' . $ev . '\s*\(\s*' . $b6 . '\s*\(/i' => $ev . '(' . $b6 . '())',
3819 '/' . $ev . '\s*\(\s*' . $gz . '\s*\(/i' => $ev . '(' . $gz . '())',
3820 '/' . $ev . '\s*\(\s*' . $gu . '\s*\(/i' => $ev . '(' . $gu . '())',
3821 '/' . $ev . '\s*\(\s*' . $sr . '\s*\(/i' => $ev . '(' . $sr . '())',
3822 '/' . $ev . '\s*\(\s*' . $hb . '\s*\(/i' => $ev . '(' . $hb . '())',
3823 '/' . $as . '\s*\(\s*' . $b6 . '\s*\(/i' => $as . '(' . $b6 . '())',
3824 '/' . $ev . '\s*\(\s*\$[a-z_]+\s*\(/i' => $ev . '($variable())',
3825 '/' . $ev . '\s*\(\s*' . $ss . '\s*\(/i' => $ev . '(' . $ss . '())',
3826 );
3827
3828 foreach ( $obfuscation_combos as $regex => $label ) {
3829 if ( preg_match( $regex, $content ) ) {
3830 return $label;
3831 }
3832 }
3833
3834 // Deprecated dynamic function constructor, nearly always malicious in modern code
3835 if ( ! empty( $cf ) && $this->needle_present( $content, $cf ) ) {
3836 return $cf . ')';
3837 }
3838
3839 // Remote fetch piped into unserialize: a PHP object-injection / supply-chain
3840 // vector seen in trojanized or nulled plugins (download a payload from a
3841 // remote URL and unserialize it). Requires a REAL unserialize() call
3842 // (not maybe_unserialize() / igbinary_unserialize(), which are common and
3843 // safe) sitting CLOSE TO a remote-fetch call. Earlier releases only
3844 // checked that both strings appeared somewhere in the file, which
3845 // false-positived on legitimate code using both in unrelated methods
3846 // (e.g. a theme reading a transient with maybe_unserialize() while
3847 // fetching its public IP with wp_remote_get()).
3848 $us = $fragments['us'] ?? '';
3849 if ( '' !== $us ) {
3850 $us_regex = '/(?<![a-z0-9_])' . preg_quote( $us, '/' ) . '\s*\(/i';
3851 $remote_fetchers = array(
3852 $fragments['wr'] ?? '', // wp_remote_get
3853 $fragments['rb'] ?? '', // wp_remote_retrieve_body
3854 $fragments['ce'] ?? '', // curl_exec
3855 );
3856 foreach ( $remote_fetchers as $rf ) {
3857 if ( '' === $rf ) {
3858 continue;
3859 }
3860 $rf_regex = '/(?<![a-z0-9_])' . preg_quote( $rf, '/' ) . '\s*\(/i';
3861 if ( $this->pattern_near( $content, $us_regex, $rf_regex, 600 ) ) {
3862 return $rf . '() + ' . $us . '() remote deserialization';
3863 }
3864 }
3865
3866 // file_get_contents() is treated separately from the fetchers
3867 // above: those are unambiguously remote, while file_get_contents
3868 // is PHP's most common LOCAL file reader, and reading a local
3869 // path right next to unserialize() is a legitimate pattern
3870 // (settings import/export, PSR-6 file caches shipped in premium
3871 // plugins, which have no wp.org checksums so this heuristic is
3872 // their only filter). It only acts as a remote fetcher when its
3873 // argument is a URL, so the combo additionally requires a
3874 // remote-scheme literal near the call before it fires.
3875 $fg = $fragments['fg'] ?? '';
3876 if ( '' !== $fg ) {
3877 $fg_regex = '/(?<![a-z0-9_])' . preg_quote( $fg, '/' ) . '\s*\(/i';
3878 $scheme_regex = '/(?:https?|ftps?):\/\/|php:\/\/input/i';
3879
3880 if ( $this->pattern_near( $content, $us_regex, $fg_regex, 600 )
3881 && $this->pattern_near( $content, $fg_regex, $scheme_regex, 600 ) ) {
3882 return $fg . '() + ' . $us . '() remote deserialization';
3883 }
3884 }
3885 }
3886
3887 // preg_replace with /e modifier (arbitrary code execution, deprecated)
3888 if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
3889 return 'preg_replace /e modifier';
3890 }
3891
3892 // Long hex-encoded strings (obfuscated payloads)
3893 if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
3894 return 'hex-encoded string';
3895 }
3896
3897 // Heavily concatenated chr() calls (char-by-char obfuscation)
3898 if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
3899 return 'chr() concatenation obfuscation';
3900 }
3901
3902 // Detect dangerous function names built from string concatenation
3903 if ( preg_match_all( '/\$([a-z_]\w*)\s*=\s*((?:["\'][a-z0-9_]*["\']\s*\.\s*)+["\'][a-z0-9_]*["\'])\s*;/i', $content, $matches, PREG_SET_ORDER ) ) {
3904 $dangerous_names = array();
3905 if ( ! empty( $patterns_data['dangerous_names'] ) ) {
3906 foreach ( $patterns_data['dangerous_names'] as $encoded_name ) {
3907 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
3908 $dangerous_names[] = base64_decode( $encoded_name );
3909 }
3910 }
3911
3912 foreach ( $matches as $match ) {
3913 $combined = strtolower( preg_replace( '/["\'\s\.]/', '', $match[2] ) );
3914 if ( in_array( $combined, $dangerous_names, true ) ) {
3915 $var_pattern = '/\$' . preg_quote( $match[1], '/' ) . '\s*\(/';
3916 if ( preg_match( $var_pattern, $content ) ) {
3917 return 'obfuscated ' . $combined . '() call';
3918 }
3919 }
3920 }
3921 }
3922
3923 return false;
3924 }
3925
3926 /**
3927 * Whether a scan needle is present as a real token rather than glued inside
3928 * a longer identifier.
3929 *
3930 * Function-call needles (an identifier followed by "(") are matched with a
3931 * left word boundary, so "file_get_contents(" no longer matches
3932 * "wpcom_vip_file_get_contents(", "unserialize(" no longer matches
3933 * "maybe_unserialize(", and "eval(" no longer matches "retrieval(". Needles
3934 * that are not plain identifiers (such as the "$_GET[" superglobal probes)
3935 * keep a plain case-insensitive substring search.
3936 *
3937 * @param string $content File content.
3938 * @param string $needle Decoded needle (e.g. "eval(", "$_GET[").
3939 * @return bool
3940 */
3941 private function needle_present( $content, $needle ) {
3942 if ( '' === $needle ) {
3943 return false;
3944 }
3945 if ( preg_match( '/^[a-z_][a-z0-9_]*\($/i', $needle ) ) {
3946 $fn = rtrim( $needle, '(' );
3947 return (bool) preg_match( '/(?<![a-z0-9_])' . preg_quote( $fn, '/' ) . '\s*\(/i', $content );
3948 }
3949 return stripos( $content, $needle ) !== false;
3950 }
3951
3952 /**
3953 * Whether two patterns both occur within $window bytes of each other.
3954 *
3955 * Used to require that correlated malware signals (for example a remote
3956 * fetch and an unserialize call) sit in the same code path instead of
3957 * merely coexisting somewhere in the file, which was a false-positive
3958 * source when only their presence was checked.
3959 *
3960 * @param string $content File content.
3961 * @param string $regex_a First anchored pattern (with delimiters and flags).
3962 * @param string $regex_b Second anchored pattern (with delimiters and flags).
3963 * @param int $window Maximum byte distance between a match of each.
3964 * @return bool
3965 */
3966 private function pattern_near( $content, $regex_a, $regex_b, $window ) {
3967 if ( ! preg_match_all( $regex_a, $content, $m_a, PREG_OFFSET_CAPTURE ) ) {
3968 return false;
3969 }
3970 if ( ! preg_match_all( $regex_b, $content, $m_b, PREG_OFFSET_CAPTURE ) ) {
3971 return false;
3972 }
3973 foreach ( $m_a[0] as $a ) {
3974 foreach ( $m_b[0] as $b ) {
3975 if ( abs( $a[1] - $b[1] ) <= $window ) {
3976 return true;
3977 }
3978 }
3979 }
3980 return false;
3981 }
3982
3983 /**
3984 * Whether a file is a text file worth normalizing before the fallback hash
3985 * comparison in hash_matches_published().
3986 *
3987 * @param string $file_path Absolute path.
3988 * @return bool
3989 */
3990 private function is_text_file( $file_path ) {
3991 $text_ext = array(
3992 'php', 'php3', 'php4', 'php5', 'php7', 'phtml',
3993 'js', 'css', 'html', 'htm', 'xml', 'svg',
3994 'txt', 'md', 'json', 'po', 'pot', 'yml', 'yaml', 'ini', 'csv',
3995 );
3996 return in_array( strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ), $text_ext, true );
3997 }
3998
3999 /**
4000 * Normalize a relative path for checksum-key comparison: forward slashes,
4001 * no doubled slashes, no leading "./" or "/". Case is preserved because
4002 * plugin and theme file systems are case-sensitive on most hosts.
4003 *
4004 * @param string $path Relative path.
4005 * @return string
4006 */
4007 private function normalize_rel_path( $path ) {
4008 $path = str_replace( '\\', '/', $path );
4009 $path = preg_replace( '#/+#', '/', $path );
4010 if ( 0 === strpos( $path, './' ) ) {
4011 $path = substr( $path, 2 );
4012 }
4013 return ltrim( $path, '/' );
4014 }
4015
4016 /**
4017 * Load scan patterns from external JSON file
4018 *
4019 * Patterns are stored in a JSON file with base64-encoded values
4020 * to prevent hosting WAF/antimalware from flagging the scanner
4021 * PHP file as suspicious.
4022 *
4023 * @return array Patterns data.
4024 */
4025 private function load_scan_patterns() {
4026 static $cached = null;
4027
4028 if ( null !== $cached ) {
4029 return $cached;
4030 }
4031
4032 $file = VIGILANTE_INCLUDES_DIR . 'scan-patterns.json';
4033
4034 if ( ! file_exists( $file ) ) {
4035 $cached = array();
4036 return $cached;
4037 }
4038
4039 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read, not remote.
4040 $json = file_get_contents( $file );
4041 $cached = json_decode( $json, true );
4042
4043 if ( ! is_array( $cached ) ) {
4044 $cached = array();
4045 }
4046
4047 return $cached;
4048 }
4049
4050 /**
4051 * Detect extra files in a directory that are not in checksums
4052 * (potential backdoors injected into plugins/themes)
4053 *
4054 * When $suspicious_only is true (no checksums available), only files
4055 * with suspicious code patterns are reported. This avoids flooding
4056 * results with every PHP file from plugins/themes not on WordPress.org.
4057 *
4058 * @param string $directory Directory to scan.
4059 * @param array $checksums Known checksums from WordPress.org (empty if unavailable).
4060 * @param string $type 'plugin' or 'theme'.
4061 * @param string $name Plugin or theme name.
4062 * @param bool $suspicious_only Only report files with suspicious patterns.
4063 * @return array Array with 'suspicious' and 'extra' sub-arrays.
4064 */
4065 private function detect_extra_files( $directory, $checksums, $type, $name, $suspicious_only = false ) {
4066 $found = array(
4067 'suspicious' => array(),
4068 'extra' => array(),
4069 );
4070 $max_extra = 50; // Limit to prevent timeout on large plugins
4071 $count = 0;
4072
4073 // Only check PHP files for performance
4074 $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar' );
4075
4076 // Pre-normalize the checksum keys once so path-shape differences
4077 // (Windows backslashes, a leading "./", doubled slashes) don't make a
4078 // known file look "extra" and get scanned or flagged. Case is preserved.
4079 $known_normalized = array();
4080 foreach ( array_keys( $checksums ) as $known_file ) {
4081 $known_normalized[ $this->normalize_rel_path( $known_file ) ] = true;
4082 }
4083
4084 try {
4085 $iterator = new RecursiveIteratorIterator(
4086 new RecursiveDirectoryIterator( $directory, RecursiveDirectoryIterator::SKIP_DOTS ),
4087 RecursiveIteratorIterator::LEAVES_ONLY
4088 );
4089
4090 foreach ( $iterator as $file ) {
4091 if ( $this->is_time_exceeded() || $count >= $max_extra ) {
4092 break;
4093 }
4094
4095 $file_path = $file->getPathname();
4096 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
4097
4098 // Only check PHP files
4099 if ( ! in_array( $extension, $php_extensions, true ) ) {
4100 continue;
4101 }
4102
4103 // Get relative path within plugin/theme directory, normalized so
4104 // path-shape quirks don't misclassify a known file as "extra".
4105 $norm_path = str_replace( '\\', '/', $file_path );
4106 $norm_dir = str_replace( '\\', '/', $directory );
4107 $relative_to_dir = $this->normalize_rel_path( str_replace( $norm_dir . '/', '', $norm_path ) );
4108
4109 // Skip if file is in the checksums (it's known)
4110 if ( isset( $known_normalized[ $relative_to_dir ] ) ) {
4111 continue;
4112 }
4113
4114 // Skip excluded paths
4115 if ( $this->is_path_excluded( $file_path ) ) {
4116 continue;
4117 }
4118
4119 // Skip "Silence is golden" placeholder index.php files used by
4120 // WordPress core and many plugins to prevent directory listings.
4121 // The check is content-based — an attacker cannot bypass it by
4122 // simply naming a payload file index.php.
4123 if ( $this->is_silence_golden_file( $file_path ) ) {
4124 continue;
4125 }
4126
4127 $count++;
4128 $relative = str_replace( ABSPATH, '', $file_path );
4129 $pattern = false;
4130
4131 // Check for suspicious content in extra files
4132 // Use strict mode for plugins without checksums to avoid false positives
4133 if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
4134 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4135 $pattern = $this->detect_suspicious_pattern( $content, $suspicious_only );
4136 }
4137
4138 $item = array(
4139 'file' => $relative,
4140 $type => $name,
4141 );
4142
4143 if ( $pattern ) {
4144 // Suspicious content: promote to suspicious category
4145 $item['type'] = 'suspicious_' . $type;
4146 /* translators: 1: Plugin or theme name, 2: Suspicious pattern found */
4147 $item['reason'] = sprintf( __( 'Injected file in %1$s with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
4148 $found['suspicious'][] = $item;
4149 } elseif ( ! $suspicious_only ) {
4150 // No suspicious patterns and checksums available: report as extra
4151 // Skipped in suspicious_only mode (no checksums) to avoid noise
4152 $item['type'] = 'extra_' . $type;
4153 $item['reason'] = __( 'PHP file not present in original distribution', 'vigilante' );
4154 $found['extra'][] = $item;
4155 }
4156 }
4157 } catch ( Exception $e ) {
4158 // Ignore iterator errors
4159 }
4160
4161 return $found;
4162 }
4163
4164 /**
4165 * Whether the given file is a trivial "Silence is golden" placeholder.
4166 *
4167 * WordPress core and most plugins drop an empty or near-empty index.php
4168 * inside their directories to block directory listings on misconfigured
4169 * servers. Those files trip the extra/suspicious detector even though
4170 * they're harmless. We whitelist them by content (not by name) so an
4171 * attacker cannot bypass the rule simply by calling a payload index.php.
4172 *
4173 * @param string $file_path Absolute path to the file being scanned.
4174 * @return bool True when the file is a known harmless placeholder.
4175 */
4176 private function is_silence_golden_file( $file_path ) {
4177 if ( 'index.php' !== basename( $file_path ) ) {
4178 return false;
4179 }
4180
4181 // Cap to avoid reading large files just to check this. Real placeholders
4182 // are always tiny (< 100 bytes); anything bigger isn't one.
4183 $size = @filesize( $file_path );
4184 if ( false === $size || $size > 256 ) {
4185 return false;
4186 }
4187
4188 $content = @file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4189 if ( false === $content ) {
4190 return false;
4191 }
4192
4193 $normalized = strtolower( trim( str_replace( array( "\r\n", "\r" ), "\n", $content ) ) );
4194
4195 $known = array(
4196 '',
4197 '<?php',
4198 '<?php //silence is golden.',
4199 '<?php // silence is golden.',
4200 '<?php //silence is golden',
4201 '<?php // silence is golden',
4202 );
4203
4204 return in_array( $normalized, $known, true );
4205 }
4206
4207 /**
4208 * Check if a path is excluded from scanning
4209 *
4210 * @param string $path File path.
4211 * @return bool
4212 */
4213 private function is_path_excluded( $path ) {
4214 $excluded = $this->options['excluded_paths'] ?? array();
4215
4216 if ( empty( $excluded ) ) {
4217 return false;
4218 }
4219
4220 $relative = $this->relative_path( $path );
4221
4222 foreach ( $excluded as $exclude ) {
4223 $exclude = trim( trim( str_replace( '\\', '/', (string) $exclude ) ), '/' );
4224
4225 if ( '' === $exclude ) {
4226 continue;
4227 }
4228
4229 /*
4230 * Two forms, both with real boundaries. Until 2.9.9 this was a plain
4231 * strpos() over the relative path, so an exclusion matched anywhere
4232 * inside it: "cache" also silenced a plugin folder named mycache,
4233 * "logs" silenced catalogs, and nothing told the user how much had
4234 * stopped being watched.
4235 *
4236 * Anything with a slash is a path: it excludes exactly that file, or
4237 * everything under it, anchored at the root of the installation.
4238 */
4239 if ( false !== strpos( $exclude, '/' ) ) {
4240 if ( $relative === $exclude || 0 === strpos( $relative, $exclude . '/' ) ) {
4241 return true;
4242 }
4243
4244 continue;
4245 }
4246
4247 /*
4248 * A bare name excludes any folder called exactly that, at any depth,
4249 * which is what someone typing "languages" means. It has to be the
4250 * whole segment, not a fragment of one.
4251 */
4252 if ( $relative === $exclude
4253 || 0 === strpos( $relative, $exclude . '/' )
4254 || false !== strpos( $relative, '/' . $exclude . '/' ) ) {
4255 return true;
4256 }
4257 }
4258
4259 return false;
4260 }
4261
4262 /**
4263 * Path relative to the WordPress directory, with forward slashes
4264 *
4265 * @since 2.9.9
4266 *
4267 * @param string $path Absolute path.
4268 * @return string
4269 */
4270 private function relative_path( $path ) {
4271 $path = str_replace( '\\', '/', (string) $path );
4272 $root = str_replace( '\\', '/', ABSPATH );
4273
4274 if ( 0 === strpos( $path, $root ) ) {
4275 $path = substr( $path, strlen( $root ) );
4276 }
4277
4278 return ltrim( $path, '/' );
4279 }
4280
4281 /**
4282 * Check if a file extension is excluded from scanning
4283 *
4284 * @param string $path File path.
4285 * @return bool
4286 */
4287 private function is_extension_excluded( $path ) {
4288 $excluded = $this->options['excluded_extensions'] ?? array();
4289
4290 if ( empty( $excluded ) ) {
4291 return false;
4292 }
4293
4294 $extension = '.' . strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
4295 $relative = strtolower( $this->relative_path( $path ) );
4296
4297 foreach ( $excluded as $exclude ) {
4298 $exclude = strtolower( trim( (string) $exclude ) );
4299
4300 if ( '' === $exclude ) {
4301 continue;
4302 }
4303
4304 /*
4305 * An extension on its own is global, as it has always been. Since
4306 * 2.9.9 it can also be scoped to a folder, written as
4307 * wp-content/languages/*.json, because the global form is a blunt
4308 * instrument: excluding .json to quiet the translation files also
4309 * stopped watching the 173 block.json files of core.
4310 */
4311 $scope = '';
4312
4313 if ( false !== strpos( $exclude, '*' ) ) {
4314 $parts = explode( '*', $exclude, 2 );
4315 $scope = trim( $parts[0], '/' );
4316 $exclude = $parts[1];
4317 }
4318
4319 if ( '' === $exclude ) {
4320 continue;
4321 }
4322
4323 // Support both ".log" and "log" formats
4324 if ( 0 !== strpos( $exclude, '.' ) ) {
4325 $exclude = '.' . $exclude;
4326 }
4327
4328 if ( $exclude !== $extension ) {
4329 continue;
4330 }
4331
4332 if ( '' === $scope ) {
4333 return true;
4334 }
4335
4336 if ( $relative === $scope || 0 === strpos( $relative, $scope . '/' ) ) {
4337 return true;
4338 }
4339 }
4340
4341 return false;
4342 }
4343
4344 /**
4345 * Filter out ignored files from results
4346 *
4347 * @param array $items Array of scan result items.
4348 * @return array Filtered items.
4349 */
4350 private function filter_ignored( $items ) {
4351 if ( empty( $this->ignored_files ) || empty( $items ) ) {
4352 return $items;
4353 }
4354
4355 return array_values(
4356 array_filter(
4357 $items,
4358 function ( $item ) {
4359 /*
4360 * On a network, wp-config.php and the root .htaccess are not a
4361 * site's to silence: a change to them is closed by approving it,
4362 * and approving takes a network administrator since 2.11.3. The
4363 * ignore list is an option of each site, so until 2.11.8 the
4364 * administrator of the main site without network rights hid a
4365 * pending change from the network administrator's own screen by
4366 * posting the file name to the ignore handler.
4367 */
4368 if ( is_multisite() && is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
4369 return true;
4370 }
4371
4372 $file = is_array( $item ) && isset( $item['file'] ) ? $item['file'] : '';
4373 return ! in_array( $file, $this->ignored_files, true );
4374 }
4375 )
4376 );
4377 }
4378
4379 /**
4380 * Send email notification based on notify_level setting
4381 *
4382 * Supports three levels:
4383 * - 'all': notify on any issues (modified + suspicious + extra)
4384 * - 'suspicious_only': notify only when suspicious or extra files found
4385 * - 'disabled': never send
4386 *
4387 * Backward compatible with old notify_on_changes boolean.
4388 *
4389 * @param array $results Scan results.
4390 */
4391 private function maybe_send_notification( $results ) {
4392 $options = is_array( $this->options ) ? $this->options : array();
4393
4394 // Count critical_config separately from regular modified so we can treat it
4395 // as "serious" for notification level purposes (same tier as suspicious/extra).
4396 $has_critical_config = false;
4397 foreach ( $results['modified'] ?? array() as $item ) {
4398 if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
4399 $has_critical_config = true;
4400 break;
4401 }
4402 }
4403
4404 // Collect closed/removed plugins (excluding ignored slugs). These count
4405 // as "serious" for notification purposes: a closed plugin in wp.org is
4406 // a security-critical finding, same tier as a suspicious file.
4407 $closed_plugins = $this->collect_closed_plugins_for_email();
4408 $has_closed = ! empty( $closed_plugins );
4409
4410 $has_suspicious = ! empty( $results['suspicious'] ) || ! empty( $results['extra'] ) || $has_critical_config || $has_closed;
4411 $has_modified = ! empty( $results['modified'] );
4412
4413 // Instant alert: send for suspicious, extra, critical_config, modified
4414 // files, or closed plugins.
4415 $instant_alert = ! empty( $options['instant_alert'] );
4416 if ( $instant_alert && ( $has_suspicious || $has_modified ) ) {
4417 $this->send_notification( $results, 'all', $closed_plugins );
4418 return;
4419 }
4420
4421 // Determine notify level with backward compatibility
4422 $notify_level = $options['notify_level'] ?? '';
4423
4424 // Backward compat: if notify_level not set, check old boolean
4425 if ( empty( $notify_level ) ) {
4426 if ( ! empty( $options['notify_on_changes'] ) ) {
4427 $notify_level = 'all';
4428 } else {
4429 $notify_level = 'disabled';
4430 }
4431 }
4432
4433 if ( 'disabled' === $notify_level ) {
4434 return;
4435 }
4436
4437 // 'suspicious_only' treats suspicious/extra, critical_config AND closed
4438 // plugins as serious.
4439 if ( 'suspicious_only' === $notify_level && ! $has_suspicious ) {
4440 return;
4441 }
4442
4443 if ( ! $has_suspicious && ! $has_modified ) {
4444 return;
4445 }
4446
4447 $this->send_notification( $results, $notify_level, $closed_plugins );
4448 }
4449
4450 /**
4451 * Collect the closed/removed plugins (excluding ignored slugs) so they can
4452 * be folded into the scan email digest. Returns an array keyed by slug.
4453 *
4454 * @return array
4455 */
4456 private function collect_closed_plugins_for_email() {
4457 if ( empty( $this->options['check_closed_plugins'] ) ) {
4458 return array();
4459 }
4460 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
4461 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
4462 }
4463 $checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
4464 return $checker->get_closed_plugins();
4465 }
4466
4467 /**
4468 * Merge scan results
4469 *
4470 * @param array $results1 First results.
4471 * @param array $results2 Second results.
4472 * @return array Merged results.
4473 */
4474 private function merge_results( $results1, $results2 ) {
4475 return array(
4476 'scanned' => $results1['scanned'] + ( $results2['scanned'] ?? 0 ),
4477 'ok' => $results1['ok'] + ( $results2['ok'] ?? 0 ),
4478 'modified' => array_merge( $results1['modified'], $results2['modified'] ?? array() ),
4479 'missing' => array_merge( $results1['missing'] ?? array(), $results2['missing'] ?? array() ),
4480 'suspicious' => array_merge( $results1['suspicious'] ?? array(), $results2['suspicious'] ?? array() ),
4481 'extra' => array_merge( $results1['extra'] ?? array(), $results2['extra'] ?? array() ),
4482 'new' => $results1['new'] ?? array(),
4483 'errors' => array_merge( $results1['errors'] ?? array(), $results2['errors'] ?? array() ),
4484 'scan_time' => $results1['scan_time'] ?? 0,
4485 'incomplete' => $results1['incomplete'] ?? false,
4486 );
4487 }
4488
4489 /**
4490 * Send notification email about scan results
4491 *
4492 * @param array $results Scan results.
4493 * @param string $notify_level Notification level ('all' or 'suspicious_only').
4494 * @param array $closed_plugins Optional map of slug=>state-entry for closed/removed
4495 * plugins to include as a dedicated section.
4496 */
4497 private function send_notification( $results, $notify_level = 'all', $closed_plugins = array() ) {
4498 $to = Vigilante_Email_Template::get_admin_recipients();
4499 $site_name = get_bloginfo( 'name' );
4500
4501 // Split critical_config files from regular modified so they get their own
4502 // prominent section in the email, next to suspicious/extra.
4503 $critical_config = array();
4504 $regular_modified = array();
4505 foreach ( $results['modified'] ?? array() as $item ) {
4506 if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
4507 // Synthesize a reason string with size + line-diff info so get_section_html shows it
4508 $baseline_size = $item['baseline_size'] ?? 0;
4509 $current_size = $item['current_size'] ?? 0;
4510 $added_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['added'] ?? array() ) : 0;
4511 $removed_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['removed'] ?? array() ) : 0;
4512 $diff_unavail = is_array( $item['diff'] ?? null ) && ! empty( $item['diff']['unavailable'] );
4513
4514 $reason = sprintf(
4515 /* translators: 1: baseline size, 2: current size */
4516 __( '%1$s → %2$s bytes', 'vigilante' ),
4517 number_format_i18n( $baseline_size ),
4518 number_format_i18n( $current_size )
4519 );
4520 if ( ! $diff_unavail ) {
4521 $reason .= sprintf( ' (+%d / -%d %s)', $added_count, $removed_count, __( 'lines', 'vigilante' ) );
4522 }
4523
4524 $item['reason'] = $reason;
4525 $critical_config[] = $item;
4526 } else {
4527 $regular_modified[] = $item;
4528 }
4529 }
4530
4531 $suspicious_count = count( $results['suspicious'] ?? array() );
4532 $extra_count = count( $results['extra'] ?? array() );
4533 $critical_config_count = count( $critical_config );
4534 $modified_count = count( $regular_modified );
4535 $closed_count = count( $closed_plugins );
4536
4537 // Use more urgent subject when suspicious files, critical config changes
4538 // or closed plugins are found (all three are security-critical).
4539 if ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 ) {
4540 $subject = sprintf(
4541 /* translators: %s: Site name */
4542 __( '[%s] SECURITY ALERT: File integrity issues detected', 'vigilante' ),
4543 $site_name
4544 );
4545 } else {
4546 $subject = sprintf(
4547 /* translators: %s: Site name */
4548 __( '[%s] File integrity issues detected', 'vigilante' ),
4549 $site_name
4550 );
4551 }
4552
4553 // Build HTML email using template wrapper
4554 $inner = '';
4555
4556 // Summary counts
4557 $inner .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom:20px;">';
4558 $inner .= '<tr>';
4559 if ( $suspicious_count > 0 ) {
4560 $inner .= $this->get_stat_cell( $suspicious_count, __( 'Suspicious', 'vigilante' ), '#d63638' );
4561 }
4562 if ( $extra_count > 0 ) {
4563 $inner .= $this->get_stat_cell( $extra_count, __( 'Extra', 'vigilante' ), '#b32d2e' );
4564 }
4565 if ( $critical_config_count > 0 ) {
4566 $inner .= $this->get_stat_cell( $critical_config_count, __( 'Critical', 'vigilante' ), '#e36210' );
4567 }
4568 if ( $closed_count > 0 ) {
4569 $inner .= $this->get_stat_cell( $closed_count, __( 'Closed', 'vigilante' ), '#d63638' );
4570 }
4571 if ( 'all' === $notify_level && $modified_count > 0 ) {
4572 $inner .= $this->get_stat_cell( $modified_count, __( 'Modified', 'vigilante' ), '#dba617' );
4573 }
4574 $inner .= $this->get_stat_cell( $results['scanned'] ?? 0, __( 'Scanned', 'vigilante' ), '#50575e' );
4575 $inner .= '</tr></table>';
4576
4577 // Suspicious files section
4578 if ( ! empty( $results['suspicious'] ) ) {
4579 $inner .= $this->get_section_html(
4580 __( 'Suspicious files', 'vigilante' ),
4581 __( 'These files may contain malicious code. Review immediately.', 'vigilante' ),
4582 $results['suspicious'],
4583 '#d63638',
4584 '#fef1f1',
4585 20,
4586 true
4587 );
4588 }
4589
4590 // Extra files section
4591 if ( ! empty( $results['extra'] ) ) {
4592 $inner .= $this->get_section_html(
4593 __( 'Extra files', 'vigilante' ),
4594 __( 'PHP files not in the original WordPress.org distribution.', 'vigilante' ),
4595 $results['extra'],
4596 '#b32d2e',
4597 '#fdf6f4',
4598 20,
4599 true
4600 );
4601 }
4602
4603 // Critical config files section (wp-config.php, .htaccess modified outside Vigilante)
4604 if ( ! empty( $critical_config ) ) {
4605 $inner .= $this->get_section_html(
4606 __( 'Critical config files modified', 'vigilante' ),
4607 __( 'These files are common targets for code injection. Review the changes and approve if they are legitimate.', 'vigilante' ),
4608 $critical_config,
4609 '#e36210',
4610 '#fdf2e6',
4611 10,
4612 true
4613 );
4614 }
4615
4616 // Modified files section (only if notify_level is 'all')
4617 if ( 'all' === $notify_level && ! empty( $regular_modified ) ) {
4618 $inner .= $this->get_section_html(
4619 __( 'Modified files', 'vigilante' ),
4620 __( 'Checksum mismatch with WordPress.org originals.', 'vigilante' ),
4621 $regular_modified,
4622 '#dba617',
4623 '#fdf8e8',
4624 15,
4625 false
4626 );
4627 }
4628
4629 // Closed + Removed plugins section.
4630 // Same tier as suspicious files: WordPress.org has flagged the plugin as
4631 // closed or removed, the site keeps running its code, and ignoring the
4632 // finding is an explicit per-slug action by the admin.
4633 if ( $closed_count > 0 ) {
4634 $inner .= $this->build_closed_plugins_email_section( $closed_plugins );
4635 }
4636
4637 // CTA button
4638 $inner .= Vigilante_Email_Template::button(
4639 admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-last-scan' ),
4640 __( 'Review in Vigilant', 'vigilante' )
4641 );
4642
4643 $is_alert = ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 );
4644 $title = $is_alert
4645 ? __( 'Security alert', 'vigilante' )
4646 : __( 'File integrity report', 'vigilante' );
4647
4648 Vigilante_Email_Template::send( $to, $subject, $title, $inner, $is_alert );
4649 }
4650
4651 /**
4652 * Build the closed + removed plugins block for the scan email.
4653 *
4654 * Reuses the same visual treatment as the suspicious files section
4655 * (red accent, danger description) because the security tier is the
4656 * same: WordPress.org has marked the plugin as compromised or removed.
4657 *
4658 * @param array $closed_plugins Map of slug=>state entry.
4659 * @return string HTML block.
4660 */
4661 private function build_closed_plugins_email_section( $closed_plugins ) {
4662 $color = '#d63638';
4663 $bg_color = '#fef1f1';
4664 $title = __( 'Closed + Removed plugins', 'vigilante' );
4665 $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' );
4666
4667 $html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">';
4668 $html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>';
4669 $html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $desc ) . '</p>';
4670
4671 $html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">';
4672 foreach ( $closed_plugins as $slug => $entry ) {
4673 $name = isset( $entry['name'] ) ? $entry['name'] : $slug;
4674 $version = isset( $entry['version'] ) ? $entry['version'] : '';
4675 $state = isset( $entry['state'] ) ? $entry['state'] : '';
4676 $state_label = 'closed' === $state ? __( 'Closed', 'vigilante' ) : __( 'Removed', 'vigilante' );
4677 $closed_date = isset( $entry['closed_date'] ) ? $entry['closed_date'] : '';
4678 $reason = isset( $entry['closed_reason_text'] ) && '' !== $entry['closed_reason_text']
4679 ? $entry['closed_reason_text']
4680 : '';
4681
4682 $detail_bits = array();
4683 $detail_bits[] = $state_label;
4684 if ( '' !== $closed_date ) {
4685 $detail_bits[] = esc_html( $closed_date );
4686 }
4687 if ( '' !== $version ) {
4688 $detail_bits[] = 'v' . esc_html( $version );
4689 }
4690
4691 $html .= '<tr>';
4692 $html .= '<td style="padding:4px 0;color:#1d2327;font-family:Consolas,Monaco,monospace;font-size:11px;word-break:break-all;">';
4693 $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>';
4694 $html .= '</td></tr>';
4695 $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>';
4696 if ( '' !== $reason ) {
4697 $html .= '<tr><td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td></tr>';
4698 }
4699 }
4700 $html .= '</table>';
4701 $html .= '</div>';
4702
4703 return $html;
4704 }
4705
4706 /**
4707 * Get a summary stat cell for email
4708 *
4709 * @param int $count Stat count.
4710 * @param string $label Stat label.
4711 * @param string $color Color hex.
4712 * @return string HTML table cell.
4713 */
4714 private function get_stat_cell( $count, $label, $color ) {
4715 $html = '<td style="text-align:center;padding:12px 8px;">';
4716 $html .= '<div style="font-size:24px;font-weight:700;color:' . $color . ';line-height:1.2;">' . (int) $count . '</div>';
4717 $html .= '<div style="font-size:11px;color:#50575e;text-transform:uppercase;letter-spacing:0.5px;">' . esc_html( $label ) . '</div>';
4718 $html .= '</td>';
4719
4720 return $html;
4721 }
4722
4723 /**
4724 * Get an HTML section for file list in email
4725 *
4726 * @param string $title Section title.
4727 * @param string $description Section description.
4728 * @param array $files Array of file items.
4729 * @param string $color Accent color.
4730 * @param string $bg_color Background color.
4731 * @param int $max Max files to show.
4732 * @param bool $show_reason Whether to show reason column.
4733 * @return string HTML.
4734 */
4735 private function get_section_html( $title, $description, $files, $color, $bg_color, $max, $show_reason ) {
4736 $total = count( $files );
4737 $shown = array_slice( $files, 0, $max );
4738
4739 $html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">';
4740 $html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>';
4741 $html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $description ) . '</p>';
4742
4743 $html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">';
4744 foreach ( $shown as $file ) {
4745 $file_path = is_array( $file ) ? ( $file['file'] ?? '' ) : (string) $file;
4746 $reason = is_array( $file ) ? ( $file['reason'] ?? '' ) : '';
4747
4748 $html .= '<tr>';
4749 $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>';
4750 $html .= '</tr>';
4751
4752 if ( $show_reason && ! empty( $reason ) ) {
4753 $html .= '<tr>';
4754 $html .= '<td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td>';
4755 $html .= '</tr>';
4756 }
4757 }
4758 $html .= '</table>';
4759
4760 if ( $total > $max ) {
4761 $html .= '<p style="margin:8px 0 0;font-size:12px;color:#787c82;">';
4762 /* translators: %d: Number of additional files */
4763 $html .= sprintf( esc_html__( '... and %d more', 'vigilante' ), $total - $max );
4764 $html .= '</p>';
4765 }
4766
4767 $html .= '</div>';
4768
4769 return $html;
4770 }
4771
4772 /**
4773 * Add a file to the ignored list
4774 *
4775 * @param string $file_path Relative file path to ignore.
4776 * @return bool
4777 */
4778 public function ignore_file( $file_path ) {
4779 $ignored = get_option( 'vigilante_ignored_files', array() );
4780
4781 if ( ! in_array( $file_path, $ignored, true ) ) {
4782 $ignored[] = sanitize_text_field( $file_path );
4783 return update_option( 'vigilante_ignored_files', $ignored );
4784 }
4785
4786 return true;
4787 }
4788
4789 /**
4790 * Remove a file from the ignored list
4791 *
4792 * @param string $file_path Relative file path to stop ignoring.
4793 * @return bool
4794 */
4795 public function unignore_file( $file_path ) {
4796 $ignored = get_option( 'vigilante_ignored_files', array() );
4797 $ignored = array_values( array_diff( $ignored, array( $file_path ) ) );
4798
4799 return update_option( 'vigilante_ignored_files', $ignored );
4800 }
4801
4802 /**
4803 * Get the list of ignored files
4804 *
4805 * @return array
4806 */
4807 public function get_ignored_files() {
4808 return get_option( 'vigilante_ignored_files', array() );
4809 }
4810
4811 /**
4812 * Clear all ignored files
4813 *
4814 * @return bool
4815 */
4816 public function clear_ignored_files() {
4817 return delete_option( 'vigilante_ignored_files' );
4818 }
4819
4820 /**
4821 * Get last scan results
4822 *
4823 * @return array|false
4824 */
4825 public function get_last_scan_results() {
4826 return get_option( 'vigilante_last_integrity_results', false );
4827 }
4828
4829 /**
4830 * Get last scan time
4831 *
4832 * @return int|false
4833 */
4834 public function get_last_scan_time() {
4835 return get_option( 'vigilante_last_integrity_scan', false );
4836 }
4837
4838 /**
4839 * Clear stored hashes
4840 *
4841 * @return bool
4842 */
4843 public function clear_hashes() {
4844 return $this->database->clear_file_hashes();
4845 }
4846 }