PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-file-integrity.php

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

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