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

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

5,240 lines 213.5 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 if ( '.' !== $slug && '' !== $slug ) {
1776 $targets['plugin'][] = $slug;
1777 }
1778 }
1779 } elseif ( 'theme' === $type && ! empty( $hook_extra['themes'] ) && is_array( $hook_extra['themes'] ) ) {
1780 foreach ( $hook_extra['themes'] as $slug ) {
1781 $slug = (string) $slug;
1782 if ( '' !== $slug ) {
1783 $targets['theme'][] = $slug;
1784 }
1785 }
1786 }
1787
1788 if ( empty( $targets ) ) {
1789 return;
1790 }
1791
1792 // Open a short grace window per slug (30 minutes; closed earlier once the
1793 // verifier runs). Kept short on purpose: with WP-Cron disabled the
1794 // verifier never fires, so a longer window would leave a just-updated
1795 // slug unscanned. After it expires the normal scan resumes.
1796 foreach ( $targets as $t => $slugs ) {
1797 foreach ( array_unique( $slugs ) as $slug ) {
1798 set_transient( 'vigilante_fi_grace_' . $t . '_' . md5( $slug ), 1, 30 * MINUTE_IN_SECONDS );
1799 }
1800 }
1801
1802 // Verify shortly after the update settles. A single delayed event keeps
1803 // the heavy work out of the update request itself.
1804 if ( ! wp_next_scheduled( 'vigilante_fi_postupdate_verify', array( $targets ) ) ) {
1805 wp_schedule_single_event( time() + 90, 'vigilante_fi_postupdate_verify', array( $targets ) );
1806 }
1807 }
1808
1809 /**
1810 * Immediate post-update verification callback (vigilante_fi_postupdate_verify).
1811 *
1812 * @param array $targets type => list of slugs.
1813 */
1814 public function run_postupdate_verify( $targets ) {
1815 if ( ! is_array( $targets ) ) {
1816 return;
1817 }
1818 foreach ( $targets as $type => $slugs ) {
1819 if ( 'plugin' !== $type && 'theme' !== $type ) {
1820 continue;
1821 }
1822 foreach ( array_unique( (array) $slugs ) as $slug ) {
1823 $this->verify_updated_slug( $type, (string) $slug );
1824 }
1825 }
1826 }
1827
1828 /**
1829 * Verify a single just-updated plugin/theme against fresh wp.org checksums.
1830 *
1831 * The grace window forces get_*_checksums() to fetch a live manifest, so this
1832 * never compares against a manifest cached during wp.org's propagation lag.
1833 * Outcomes: checksums not published yet => leave the window to expire and let
1834 * the next scan re-check; all files match => close the window early; a file
1835 * matches no published hash => a genuine mismatch (right after a legit update
1836 * that points at a tampered package), logged as a warning, and the window is
1837 * closed so the finding also surfaces in the normal scan.
1838 *
1839 * @param string $type 'plugin' or 'theme'.
1840 * @param string $slug Slug.
1841 */
1842 private function verify_updated_slug( $type, $slug ) {
1843 $grace_key = 'vigilante_fi_grace_' . $type . '_' . md5( $slug );
1844
1845 if ( 'plugin' === $type ) {
1846 if ( ! function_exists( 'get_plugins' ) ) {
1847 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1848 }
1849 $version = '';
1850 $name = $slug;
1851 foreach ( get_plugins() as $file => $data ) {
1852 if ( dirname( $file ) === $slug ) {
1853 $version = $data['Version'] ?? '';
1854 $name = $data['Name'] ?? $slug;
1855 break;
1856 }
1857 }
1858 $checksums = $this->get_plugin_checksums( $slug, $version );
1859 $base_dir = WP_PLUGIN_DIR . '/' . $slug;
1860 } else {
1861 $theme = wp_get_theme( $slug );
1862 if ( ! $theme->exists() ) {
1863 delete_transient( $grace_key );
1864 return;
1865 }
1866 $version = $theme->get( 'Version' );
1867 $name = $theme->get( 'Name' );
1868 $checksums = $this->get_theme_checksums( $slug, $version );
1869 $base_dir = $theme->get_stylesheet_directory();
1870 }
1871
1872 // Checksums not available yet (propagation lag): leave the grace window
1873 // to expire; the next scheduled scan re-verifies once wp.org publishes.
1874 if ( is_wp_error( $checksums ) || 'not_found' === $checksums || ! is_array( $checksums ) ) {
1875 if ( $this->activity_log ) {
1876 $this->activity_log->log(
1877 'file',
1878 'postupdate_pending',
1879 sprintf(
1880 /* translators: 1: Plugin or theme name. */
1881 __( 'Post-update verification pending for %1$s: WordPress.org has not published the new version checksums yet. It will be re-verified automatically.', 'vigilante' ),
1882 $name
1883 ),
1884 array(
1885 'type' => $type,
1886 'slug' => $slug,
1887 'version' => $version,
1888 ),
1889 'info'
1890 );
1891 }
1892 return;
1893 }
1894
1895 // Compare every shipped file against the fresh manifest.
1896 $mismatched = array();
1897 foreach ( $checksums as $file => $expected ) {
1898 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
1899 continue;
1900 }
1901 $path = $base_dir . '/' . $file;
1902 if ( $this->is_path_excluded( $path ) || $this->is_extension_excluded( $path ) ) {
1903 continue;
1904 }
1905 // Honor the user ignore list, exactly as run_scan()'s filter_ignored()
1906 // does, so the verifier never warns about a file the user silenced.
1907 $rel = $type . 's/' . $slug . '/' . $file;
1908 if ( in_array( $rel, (array) $this->ignored_files, true ) ) {
1909 continue;
1910 }
1911 if ( ! file_exists( $path ) ) {
1912 continue;
1913 }
1914 if ( ! $this->hash_matches_published( $path, $expected ) ) {
1915 $mismatched[] = $rel;
1916 }
1917 }
1918
1919 // Verified clean: close the grace window so normal scanning resumes.
1920 if ( empty( $mismatched ) ) {
1921 delete_transient( $grace_key );
1922 if ( $this->activity_log ) {
1923 $this->activity_log->log(
1924 'file',
1925 'postupdate_verified',
1926 sprintf(
1927 /* translators: 1: Plugin or theme name. */
1928 __( 'Post-update verification passed: %1$s matches the WordPress.org distribution.', 'vigilante' ),
1929 $name
1930 ),
1931 array(
1932 'type' => $type,
1933 'slug' => $slug,
1934 'version' => $version,
1935 ),
1936 'info'
1937 );
1938 }
1939 return;
1940 }
1941
1942 // Genuine mismatch right after a legitimate update: likely a tampered
1943 // package. Close the window so the normal scan also surfaces it, and log
1944 // a warning (Audit Alerts escalates warnings if configured).
1945 delete_transient( $grace_key );
1946 if ( $this->activity_log ) {
1947 $this->activity_log->log(
1948 'file',
1949 'postupdate_mismatch',
1950 sprintf(
1951 /* translators: 1: Number of files, 2: Plugin or theme name. */
1952 _n(
1953 'Post-update integrity check failed: %1$d file in %2$s does not match the WordPress.org distribution.',
1954 'Post-update integrity check failed: %1$d files in %2$s do not match the WordPress.org distribution.',
1955 count( $mismatched ),
1956 'vigilante'
1957 ),
1958 count( $mismatched ),
1959 $name
1960 ),
1961 array(
1962 'type' => $type,
1963 'slug' => $slug,
1964 'version' => $version,
1965 'files' => array_slice( $mismatched, 0, 50 ),
1966 ),
1967 'warning'
1968 );
1969 }
1970 }
1971
1972 /**
1973 * Run a full integrity scan
1974 *
1975 * @return array Scan results.
1976 */
1977 public function run_scan() {
1978 // Initialize scan timer
1979 $this->scan_start_time = microtime( true );
1980
1981 $results = array(
1982 'scanned' => 0,
1983 'ok' => 0,
1984 'modified' => array(),
1985 'missing' => array(),
1986 'suspicious' => array(),
1987 'extra' => array(),
1988 'new' => array(),
1989 'errors' => array(),
1990 'scan_time' => 0,
1991 'incomplete' => false,
1992 );
1993
1994 // Use settings from options page
1995 $options = is_array( $this->options ) ? $this->options : array();
1996
1997 // Scan uploads for suspicious files FIRST (highest security priority)
1998 // PHP files in uploads are almost always malware
1999 if ( ! empty( $options['scan_uploads'] ) && ! $this->is_time_exceeded() ) {
2000 $upload_results = $this->scan_uploads();
2001 $results['suspicious'] = array_merge( $results['suspicious'], $upload_results['suspicious'] );
2002 $results['extra'] = array_merge( $results['extra'], $upload_results['extra'] );
2003 }
2004
2005 // Scan core files
2006 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
2007 $core_results = $this->scan_core_files();
2008 $results = $this->merge_results( $results, $core_results );
2009 }
2010
2011 // Scan root directory for non-core files (PHP = suspicious, others = additional)
2012 // Runs after core scan so checksums are already cached
2013 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
2014 $root_results = $this->scan_root_files();
2015 $results['suspicious'] = array_merge( $results['suspicious'], $root_results['suspicious'] );
2016 $results['extra'] = array_merge( $results['extra'], $root_results['extra'] );
2017 }
2018
2019 // Scan critical config files (wp-config.php, .htaccess) against stored baseline
2020 if ( ! empty( $options['scan_critical_config'] ) && ! $this->is_time_exceeded() ) {
2021 $critical_results = $this->scan_critical_root_files();
2022 $results['modified'] = array_merge( $results['modified'], $critical_results );
2023 }
2024
2025 // Scan plugins
2026 if ( ! empty( $options['scan_plugins'] ) && ! $this->is_time_exceeded() ) {
2027 $plugin_results = $this->scan_plugins();
2028 $results = $this->merge_results( $results, $plugin_results );
2029 }
2030
2031 // Scan themes
2032 if ( ! empty( $options['scan_themes'] ) && ! $this->is_time_exceeded() ) {
2033 $theme_results = $this->scan_themes();
2034 $results = $this->merge_results( $results, $theme_results );
2035 }
2036
2037 // Mark as incomplete if time was exceeded
2038 if ( $this->is_time_exceeded() ) {
2039 $results['incomplete'] = true;
2040 $results['errors'][] = __( 'Scan was incomplete due to time limit. Results may be partial.', 'vigilante' );
2041 }
2042
2043 // Filter out ignored files from all result categories
2044 $results['modified'] = $this->filter_ignored( $results['modified'] );
2045 $results['suspicious'] = $this->filter_ignored( $results['suspicious'] );
2046 $results['extra'] = $this->filter_ignored( $results['extra'] );
2047
2048 $results['scan_time'] = round( microtime( true ) - $this->scan_start_time, 2 );
2049
2050 // Log the scan only if activity_log is available
2051 if ( $this->activity_log ) {
2052 $has_issues = ! empty( $results['modified'] ) || ! empty( $results['suspicious'] ) || ! empty( $results['extra'] );
2053 $severity = $has_issues ? 'warning' : 'info';
2054
2055 $this->activity_log->log(
2056 'file',
2057 'integrity_scan',
2058 sprintf(
2059 /* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */
2060 __( 'File integrity scan completed: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' ),
2061 $results['scanned'],
2062 count( $results['modified'] ),
2063 count( $results['suspicious'] ),
2064 count( $results['extra'] )
2065 ),
2066 array(
2067 'scanned' => $results['scanned'],
2068 'modified' => count( $results['modified'] ),
2069 'suspicious' => count( $results['suspicious'] ),
2070 'extra' => count( $results['extra'] ),
2071 'scan_time' => $results['scan_time'],
2072 'incomplete' => $results['incomplete'],
2073 ),
2074 $severity
2075 );
2076 }
2077
2078 // Closed plugins check: queries the wp.org repository for the closure status
2079 // of every installed plugin slug. Independent of the file-level scan_* toggles
2080 // (gated by its own `check_closed_plugins` toggle in Scan Scope). Runs BEFORE
2081 // the notification call so closed plugins are folded into the scan email
2082 // (instead of triggering a separate one-shot). Quick (~10 s for 50 plugins).
2083 //
2084 // suppress_email=true: this entry point is the file integrity scan; the
2085 // daily plugin-status cron passes suppress_email=false so urgent closures
2086 // still produce an immediate alert when the file scan is on a weekly schedule.
2087 if ( ! empty( $options['check_closed_plugins'] ) ) {
2088 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
2089 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
2090 }
2091 $closed_checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
2092 $closed_checker->check_all_plugins( true, true );
2093 }
2094
2095 // Send email notification based on notify_level (now also includes closed
2096 // plugins picked up just above).
2097 $this->maybe_send_notification( $results );
2098
2099 return $results;
2100 }
2101
2102 /**
2103 * Scan WordPress core files
2104 *
2105 * @return array Scan results.
2106 */
2107 private function scan_core_files() {
2108 $results = array(
2109 'scanned' => 0,
2110 'ok' => 0,
2111 'modified' => array(),
2112 'missing' => array(),
2113 'errors' => array(),
2114 );
2115
2116 // Get official checksums from WordPress.org
2117 $checksums = $this->get_core_checksums();
2118
2119 if ( is_wp_error( $checksums ) ) {
2120 $results['errors'][] = $checksums->get_error_message();
2121 return $results;
2122 }
2123
2124 foreach ( $checksums as $file => $expected_hash ) {
2125 // Check time limit
2126 if ( $this->is_time_exceeded() ) {
2127 break;
2128 }
2129
2130 $file_path = ABSPATH . $file;
2131
2132 // Skip excluded paths
2133 if ( $this->is_path_excluded( $file_path ) ) {
2134 continue;
2135 }
2136
2137 // Skip excluded extensions
2138 if ( $this->is_extension_excluded( $file_path ) ) {
2139 continue;
2140 }
2141
2142 // Skip known false positives (e.g. version.php, readme.html)
2143 if ( in_array( $file, $this->core_known_false_positives, true ) ) {
2144 continue;
2145 }
2146
2147 // Skip translations that travel inside the localized core ZIP but do
2148 // not belong to core. WordPress.org's localized checksum manifest
2149 // lists Akismet and the default themes' language files (8 entries on
2150 // every non-en_US locale, none on en_US), yet they are updated on the
2151 // plugin and theme cycle and are absent from the core language pack.
2152 // Deleting an unused plugin or theme, which this plugin's own audit
2153 // recommends, otherwise left permanent "missing core file" findings.
2154 if ( 0 === strpos( $file, 'wp-content/languages/plugins/' )
2155 || 0 === strpos( $file, 'wp-content/languages/themes/' ) ) {
2156 continue;
2157 }
2158
2159 $results['scanned']++;
2160
2161 if ( ! file_exists( $file_path ) ) {
2162 $results['missing'][] = array(
2163 'file' => $file,
2164 'type' => 'core',
2165 );
2166 continue;
2167 }
2168
2169 $actual_hash = md5_file( $file_path );
2170
2171 if ( $actual_hash !== $expected_hash ) {
2172 $results['modified'][] = array(
2173 'file' => $file,
2174 'type' => 'core',
2175 'expected_hash' => $expected_hash,
2176 'actual_hash' => $actual_hash,
2177 );
2178 } else {
2179 $results['ok']++;
2180 }
2181 }
2182
2183 $results['modified'] = $this->drop_stale_language_mismatches( $results['modified'], $results );
2184
2185 return $results;
2186 }
2187
2188 /**
2189 * Drop language files that only mismatch because the manifest was stale
2190 *
2191 * wp.org rebuilds the checksum manifest every time GlotPress rebuilds a
2192 * language pack, and that happens without the WordPress version moving. The
2193 * cache key here is version plus locale, so it does not expire when that
2194 * happens, and the site spends up to a day comparing today's translation
2195 * files against yesterday's manifest. That is where the bursts of "modified
2196 * core files" under wp-content/languages/ come from, and they are not
2197 * modifications at all.
2198 *
2199 * So before reporting one, the manifest is fetched again bypassing the
2200 * cache, once per scan, and whatever matches the fresh copy is dropped.
2201 * Anything still mismatching is reported as before.
2202 *
2203 * @since 2.9.9
2204 *
2205 * @param array $modified Entries flagged as modified.
2206 * @param array $results Scan results, to move the recovered files to 'ok'.
2207 * @return array Entries that are still modified.
2208 */
2209 private function drop_stale_language_mismatches( $modified, &$results ) {
2210 if ( empty( $modified ) ) {
2211 return $modified;
2212 }
2213
2214 $suspects = array();
2215 foreach ( $modified as $entry ) {
2216 if ( 0 === strpos( $entry['file'], 'wp-content/languages/' ) ) {
2217 $suspects[ $entry['file'] ] = true;
2218 }
2219 }
2220
2221 if ( empty( $suspects ) ) {
2222 return $modified;
2223 }
2224
2225 $fresh = $this->get_core_checksums( true );
2226
2227 if ( is_wp_error( $fresh ) || empty( $fresh ) ) {
2228 return $modified;
2229 }
2230
2231 $kept = array();
2232
2233 foreach ( $modified as $entry ) {
2234 $file = $entry['file'];
2235
2236 if ( ! isset( $suspects[ $file ] ) || ! isset( $fresh[ $file ] ) ) {
2237 $kept[] = $entry;
2238 continue;
2239 }
2240
2241 if ( $this->hash_matches_published( ABSPATH . $file, $fresh[ $file ] ) ) {
2242 $results['ok']++;
2243 continue;
2244 }
2245
2246 $kept[] = $entry;
2247 }
2248
2249 return $kept;
2250 }
2251
2252 /**
2253 * Scan WordPress root directory for non-core files
2254 *
2255 * Compares files in ABSPATH (non-recursive) against the official core
2256 * checksums list. PHP files not in the core distribution are flagged as
2257 * suspicious (common attack vector: info.php, shell.php, backdoors).
2258 * Non-PHP files not in the known safe list are flagged as extra/additional.
2259 * Dotfiles and known safe files (robots.txt, etc.) are skipped.
2260 *
2261 * @return array Array with 'suspicious' and 'extra' sub-arrays.
2262 */
2263 private function scan_root_files() {
2264 $found = array(
2265 'suspicious' => array(),
2266 'extra' => array(),
2267 );
2268
2269 // Get core checksums to know which root files are legitimate
2270 $checksums = $this->get_core_checksums();
2271 if ( is_wp_error( $checksums ) ) {
2272 return $found;
2273 }
2274
2275 // Build list of known core root files from checksums (only root-level, no directory prefix)
2276 $core_root_files = array();
2277 foreach ( array_keys( $checksums ) as $file ) {
2278 // Only root-level files (no directory separator)
2279 if ( false === strpos( $file, '/' ) ) {
2280 $core_root_files[] = $file;
2281 }
2282 }
2283
2284 // Also add wp-config.php which is not in checksums but is core
2285 $core_root_files[] = 'wp-config.php';
2286
2287 $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
2288
2289 // Scan only direct children of ABSPATH (not recursive)
2290 $root_path = untrailingslashit( ABSPATH );
2291 $handle = opendir( $root_path );
2292
2293 if ( ! $handle ) {
2294 return $found;
2295 }
2296
2297 while ( false !== ( $entry = readdir( $handle ) ) ) {
2298 if ( $this->is_time_exceeded() ) {
2299 break;
2300 }
2301
2302 // Skip . and ..
2303 if ( '.' === $entry || '..' === $entry ) {
2304 continue;
2305 }
2306
2307 // Skip dotfiles (.htaccess, .user.ini, .env, etc.) — handled by firewall protection
2308 if ( 0 === strpos( $entry, '.' ) ) {
2309 continue;
2310 }
2311
2312 $full_path = $root_path . '/' . $entry;
2313
2314 // Skip directories — we only care about files in root
2315 if ( is_dir( $full_path ) ) {
2316 continue;
2317 }
2318
2319 // Skip if this is a known core file
2320 if ( in_array( $entry, $core_root_files, true ) ) {
2321 continue;
2322 }
2323
2324 // Skip excluded paths
2325 if ( $this->is_path_excluded( $full_path ) ) {
2326 continue;
2327 }
2328
2329 // Skip known safe non-PHP root files
2330 if ( in_array( strtolower( $entry ), $this->known_safe_root_files, true ) ) {
2331 continue;
2332 }
2333
2334 $extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) );
2335
2336 if ( in_array( $extension, $php_extensions, true ) ) {
2337 // Check if this is a legacy WordPress core file (removed from newer versions)
2338 if ( in_array( $entry, $this->legacy_core_root_files, true ) ) {
2339 $found['extra'][] = array(
2340 'file' => $entry,
2341 'type' => 'legacy_core',
2342 'reason' => __( 'Legacy WordPress core file, removed in newer versions. Safe to delete.', 'vigilante' ),
2343 );
2344 continue;
2345 }
2346
2347 // Silence-is-golden placeholders dropped here by some setups
2348 // (e.g. WordPress installed in a subdirectory, or third-party tooling).
2349 if ( $this->is_silence_golden_file( $full_path ) ) {
2350 continue;
2351 }
2352
2353 // PHP file not in core = suspicious
2354 $reason = __( 'Non-core PHP file in WordPress root directory', 'vigilante' );
2355
2356 // Scan content for specific patterns
2357 if ( filesize( $full_path ) < 512000 ) {
2358 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2359 $pattern = $this->detect_suspicious_pattern( $content );
2360 if ( $pattern ) {
2361 /* translators: %s: Suspicious pattern found */
2362 $reason = sprintf( __( 'Non-core PHP in root with suspicious code: %s', 'vigilante' ), $pattern );
2363 }
2364 }
2365
2366 $found['suspicious'][] = array(
2367 'file' => $entry,
2368 'type' => 'php_in_root',
2369 'reason' => $reason,
2370 );
2371 } else {
2372 // Non-PHP, non-known-safe file = additional (informational)
2373 $found['extra'][] = array(
2374 'file' => $entry,
2375 'type' => 'extra_root',
2376 'reason' => __( 'Non-core file in WordPress root directory', 'vigilante' ),
2377 );
2378 }
2379 }
2380
2381 closedir( $handle );
2382
2383 return $found;
2384 }
2385
2386 // =========================================================================
2387 // Critical config file baseline monitoring (wp-config.php, .htaccess)
2388 // =========================================================================
2389
2390 /**
2391 * Scan critical root files against stored baseline hashes
2392 *
2393 * Files like wp-config.php and .htaccess have no official WordPress.org
2394 * checksum because their content is unique per installation. We maintain
2395 * our own baseline hash and alert when the file changes outside of
2396 * Vigilante's own modifications.
2397 *
2398 * On the first scan (no baseline stored yet) the baseline is created
2399 * silently — there is nothing to compare against.
2400 *
2401 * @return array Array of modified file entries (same format as core modified).
2402 */
2403 /**
2404 * Where a critical root file actually lives
2405 *
2406 * WordPress supports wp-config.php one directory above ABSPATH, guarded by
2407 * wp-settings.php not being there: that is literally what the installed core
2408 * does in wp-load.php, and it is a common hardening layout. Until 2.11.10
2409 * this module only looked inside ABSPATH, so on those installations
2410 * wp-config.php was never added to the baseline, never compared and never
2411 * mentioned: the module reported the site clean without having opened the
2412 * one file it most needs to watch. A zero is justified, never assumed. The
2413 * plugin already resolved both locations elsewhere
2414 * (Vigilante_Database_Prefix::find_wpconfig_path()), just not here. Found by
2415 * the file-by-file review of 2.11.10.
2416 *
2417 * @since 2.11.10
2418 *
2419 * @param string $filename Name of the file, such as wp-config.php.
2420 * @return string|false Absolute path, or false when it cannot be found.
2421 */
2422 private function critical_file_path( $filename ) {
2423 $root = untrailingslashit( ABSPATH );
2424 $path = $root . '/' . $filename;
2425
2426 if ( file_exists( $path ) ) {
2427 return $path;
2428 }
2429
2430 if ( 'wp-config.php' === $filename ) {
2431 $above = dirname( $root ) . '/wp-config.php';
2432
2433 // Suppressed like the core does in wp-load.php: the directory above
2434 // the install is often outside open_basedir on shared hosting, and
2435 // without the @ every scan emits a warning that can land in front of
2436 // the JSON of an AJAX scan.
2437 // 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.
2438 if ( @file_exists( $above ) && ! @file_exists( dirname( $root ) . '/wp-settings.php' ) ) {
2439 return $above;
2440 }
2441 }
2442
2443 return false;
2444 }
2445
2446 private function scan_critical_root_files() {
2447 // Before reading anything: the scan is the only thing that reaches
2448 // every site of a network on its own, through wp-cron and front-end
2449 // traffic. Hooking the cleanup to admin_init alone left every subsite
2450 // whose dashboard nobody opens with its old copy of wp-config.php,
2451 // credentials included, for as long as nobody visited it.
2452 $this->maybe_redact_stored_baseline();
2453
2454 // And claim the blocks already on disk before anything is compared,
2455 // so the first scan after updating uses the rule that will stay.
2456 $this->maybe_claim_owned_blocks();
2457
2458 $modified = array();
2459 $baseline = $this->get_critical_files_baseline();
2460 $baseline_changed = false;
2461
2462 foreach ( $this->critical_root_files as $filename ) {
2463 $full_path = $this->critical_file_path( $filename );
2464
2465 if ( false === $full_path ) {
2466 continue;
2467 }
2468
2469 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2470 if ( false === $content ) {
2471 continue;
2472 }
2473
2474 $normalized = $this->normalize_critical_file( $filename, $content );
2475 $current_hash = md5( $normalized );
2476
2477 if ( ! isset( $baseline[ $filename ] ) ) {
2478 // First time seeing this file — store baseline silently
2479 $baseline[ $filename ] = array(
2480 'hash' => $current_hash,
2481 'size' => strlen( $content ),
2482 'content' => $this->baseline_content( $filename, $normalized ),
2483 'updated' => time(),
2484 );
2485 $baseline_changed = true;
2486 continue;
2487 }
2488
2489 // Upgrade legacy baseline entries that lack content (pre-diff format)
2490 if ( ! isset( $baseline[ $filename ]['content'] ) && $baseline[ $filename ]['hash'] === $current_hash ) {
2491 $baseline[ $filename ]['content'] = $this->baseline_content( $filename, $normalized );
2492 $baseline_changed = true;
2493 continue;
2494 }
2495
2496 /*
2497 * The file has not changed, but the copy on record is not the copy
2498 * that would be stored today: an entry written before 2.11.2 with
2499 * the credentials in it, or one written before the redaction list
2500 * grew. Rewrite it.
2501 *
2502 * Only when the hash matches, and that condition is the whole
2503 * point: if the file HAD changed, this entry is the evidence of
2504 * the change that the administrator still has to review, and
2505 * rewriting it here would quietly destroy that evidence.
2506 */
2507 if ( $baseline[ $filename ]['hash'] === $current_hash ) {
2508 $expected = $this->baseline_content( $filename, $normalized );
2509
2510 if ( $expected !== $baseline[ $filename ]['content'] ) {
2511 $baseline[ $filename ]['content'] = $expected;
2512 $baseline_changed = true;
2513 }
2514
2515 continue;
2516 }
2517
2518 /*
2519 * Everything from here down is the changed file, and only that: the
2520 * branch above returns on every matching hash, so there is no third
2521 * case and no condition left to test. It used to be wrapped in an
2522 * `if` repeating the opposite comparison, which read as if some
2523 * other path could reach this point. It could not. Flagged by
2524 * @calzbert, and worth the two lines it costs to say so.
2525 *
2526 * Both sides go through the same redaction, or every credential
2527 * line would read as a change nobody made.
2528 */
2529 /*
2530 * Where the shared files do not belong to this site, the lines are
2531 * not computed at all. The diff is shown to whoever can open this
2532 * site's screen, the administrator of a subsite included, and it
2533 * was stored in the options of every subsite with the module on.
2534 * Found by the audit of the admin surface for 2.11.8. The change is
2535 * still reported, with both sizes, and the lines are read on the
2536 * main site, where the change is approved.
2537 */
2538 if ( ! Vigilante_Settings::owns_shared_files() ) {
2539 $diff = self::network_only_diff();
2540 } else {
2541 $baseline_content = $baseline[ $filename ]['content'] ?? '';
2542 $current_content = $this->baseline_content( $filename, $normalized );
2543 $diff = ( '' !== $baseline_content && '' !== $current_content )
2544 ? $this->compute_simple_diff( $baseline_content, $current_content )
2545 : array( 'added' => array(), 'removed' => array(), 'unavailable' => true );
2546
2547 // Say why there are no lines when today's copy could not be
2548 // made safe, which approving does not change: the generic
2549 // message talks about an old baseline. Cross review of 2.11.8.
2550 if ( '' === $current_content && 'wp-config.php' === $filename ) {
2551 $diff['redaction'] = true;
2552 }
2553 }
2554
2555 $modified[] = array(
2556 'file' => $filename,
2557 'type' => 'critical_config',
2558 'expected_hash' => $baseline[ $filename ]['hash'],
2559 'actual_hash' => $current_hash,
2560 'baseline_size' => $baseline[ $filename ]['size'],
2561 'current_size' => strlen( $content ),
2562 'diff' => $diff,
2563 );
2564 }
2565
2566 if ( $baseline_changed ) {
2567 $this->write_baseline( $baseline );
2568 }
2569
2570 return $modified;
2571 }
2572
2573 /**
2574 * Compute a simple line-based diff between two strings
2575 *
2576 * Returns added and removed lines with their original line numbers.
2577 * Order is preserved. Uses a simple "line present in set" approach
2578 * which works well for config files where most lines are unique.
2579 *
2580 * @param string $old Baseline content.
2581 * @param string $new Current content.
2582 * @return array Array with 'added' and 'removed' line entries.
2583 */
2584 private function compute_simple_diff( $old, $new ) {
2585 $old_lines = explode( "\n", $old );
2586 $new_lines = explode( "\n", $new );
2587
2588 // Use hash sets for O(1) lookup. Use array_flip for cheap existence check.
2589 $old_set = array_count_values( $old_lines );
2590 $new_set = array_count_values( $new_lines );
2591
2592 $removed = array();
2593 foreach ( $old_lines as $i => $line ) {
2594 // Line only considered removed if baseline has more occurrences than current
2595 if ( ! isset( $new_set[ $line ] ) || $new_set[ $line ] < ( $old_set[ $line ] ?? 0 ) ) {
2596 $removed[] = array(
2597 'line' => $i + 1,
2598 'content' => $line,
2599 );
2600 // Decrement to handle duplicates correctly
2601 if ( isset( $old_set[ $line ] ) ) {
2602 $old_set[ $line ]--;
2603 }
2604 }
2605 }
2606
2607 // Reset for added detection
2608 $old_set = array_count_values( $old_lines );
2609 $added = array();
2610 foreach ( $new_lines as $i => $line ) {
2611 if ( ! isset( $old_set[ $line ] ) || $old_set[ $line ] < ( $new_set[ $line ] ?? 0 ) ) {
2612 $added[] = array(
2613 'line' => $i + 1,
2614 'content' => $line,
2615 );
2616 if ( isset( $new_set[ $line ] ) ) {
2617 $new_set[ $line ]--;
2618 }
2619 }
2620 }
2621
2622 return array(
2623 'added' => $added,
2624 'removed' => $removed,
2625 'unavailable' => false,
2626 );
2627 }
2628
2629 /**
2630 * Normalize critical file content by removing Vigilante-managed blocks
2631 *
2632 * This ensures that changes made by Vigilante itself (security constants,
2633 * htaccess rules) do not trigger false-positive modification alerts.
2634 * Line endings are normalized to LF to prevent false positives from
2635 * editors that change CRLF/LF.
2636 *
2637 * @param string $filename File name (e.g. 'wp-config.php').
2638 * @param string $content Raw file content.
2639 * @return string Normalized content for hashing.
2640 */
2641 private function normalize_critical_file( $filename, $content, $drop_all_original = false ) {
2642 // Normalize line endings first (CRLF and CR to LF)
2643 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
2644
2645 /*
2646 * Vigilant's own blocks are left out of the hash, so rewriting them is
2647 * not reported as somebody else's change. Until 2.11.5 that covered
2648 * everything between the markers, and every line carrying the
2649 * [VIGILANTE_ORIGINAL] marker, whatever they contained. From 2.11.5 a
2650 * block is left out only if it is exactly a block Vigilant wrote (see
2651 * remember_owned_block()), and a marked line only while uncommenting it
2652 * would still give a harmless define() (see is_vigilant_original_line()).
2653 *
2654 * Until the blocks already on disk have been claimed, the old rule
2655 * applies unchanged. That is what keeps an update from changing the
2656 * hash of a file nobody touched.
2657 */
2658 $claimed = $this->owned_blocks_claimed();
2659
2660 if ( 'wp-config.php' === $filename ) {
2661 // Vigilante constants blocks (current and legacy)
2662 foreach ( $this->wpconfig_markers as $markers ) {
2663 $content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed );
2664 }
2665
2666 // Lines commented out by Vigilante (original constants)
2667 $content = preg_replace_callback(
2668 '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m',
2669 function ( $line ) use ( $claimed, $drop_all_original ) {
2670 // $drop_all_original reproduce la regla anterior a la 2.11.5 (quitar
2671 // toda linea marcada) sobre los bloques de la regla nueva. Solo lo usa
2672 // el re-base de la transicion, para decidir si la unica diferencia con
2673 // el registro aprobado son estas lineas. Ver rebase_original_line_shift().
2674 return ( ! $claimed || $drop_all_original || $this->is_vigilant_original_line( $line[0] ) ) ? '' : $line[0];
2675 },
2676 $content
2677 );
2678 } elseif ( '.htaccess' === $filename ) {
2679 // Vigilante htaccess blocks (firewall + security headers)
2680 foreach ( $this->htaccess_markers as $markers ) {
2681 $content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed );
2682 }
2683 }
2684
2685 // Collapse multiple blank lines into one (blocks removal leaves gaps)
2686 $content = preg_replace( '/\n{3,}/', "\n\n", $content );
2687
2688 return trim( $content );
2689 }
2690
2691 /**
2692 * Leave Vigilant's blocks for one pair of markers out of the content
2693 *
2694 * Before the claim, every block, as it always was. After it, only the blocks
2695 * whose fingerprint was recorded when Vigilant wrote them. A block that does
2696 * not match, edited or planted, stays in the content: it counts in the hash
2697 * and shows up in the diff.
2698 *
2699 * The match runs from marker to marker and the removal also takes the
2700 * whitespace after the block, exactly as before, so a file whose blocks are
2701 * all Vigilant's normalizes to the same text under both rules.
2702 *
2703 * @since 2.11.5
2704 *
2705 * @param string $filename 'wp-config.php' or '.htaccess'.
2706 * @param string $content Content with normalized line endings.
2707 * @param array $markers Start and end marker.
2708 * @param bool $claimed Whether the claim has run.
2709 * @return string
2710 */
2711 private function strip_vigilant_blocks( $filename, $content, $markers, $claimed ) {
2712 $pattern = '/(' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . ')\s*/s';
2713
2714 if ( ! $claimed ) {
2715 return preg_replace( $pattern, '', $content );
2716 }
2717
2718 return preg_replace_callback(
2719 $pattern,
2720 function ( $match ) use ( $filename ) {
2721 return self::is_owned_block( $filename, $match[1] ) ? '' : $match[0];
2722 },
2723 $content
2724 );
2725 }
2726
2727 /**
2728 * Whether a line carrying the original-constant marker is one Vigilant wrote
2729 *
2730 * comment_existing_constants() puts the marker in front of a define() of a
2731 * constant it manages, and uncomment_original_constants() takes it away
2732 * again whenever the constants are applied or removed, so whatever follows
2733 * the marker gets to run some day. The line is left out of the hash only
2734 * when there is nothing but indentation before the marker, nothing after it
2735 * but a harmless define() and at most a line comment, and no PHP tag
2736 * anywhere on it. That keeps it a comment today and harmless once
2737 * uncommented. Anything else counts, and shows up in the diff.
2738 *
2739 * @since 2.11.5
2740 *
2741 * @param string $line One line of wp-config.php.
2742 * @return bool
2743 */
2744 /**
2745 * Whether every marked line of a file can run nothing at all
2746 *
2747 * The question the re-base has to answer before adopting a file is whether
2748 * the lines that carry the marker are only comments. Asking a stricter one
2749 * was wrong in both directions: the first version of the guard used
2750 * is_vigilant_original_line(), which also requires the commented define to
2751 * match a known harmless shape, so it refused to re-base a perfectly inert
2752 * line carrying an unusual define, which is exactly the case the re-base
2753 * exists for, leaving the function unable to act at all. Found by the cross
2754 * review of 2.11.10.
2755 *
2756 * The second version read one line at a time and reasoned that the marker
2757 * begins with //, so a line with nothing but whitespace before it is wholly
2758 * a comment. That is true only where PHP is already reading code, and the
2759 * second cross review of 2.11.10 built three files where it is not, all of
2760 * them valid PHP, all of them passing that test and all of them running or
2761 * printing something:
2762 *
2763 * - the marked line placed BEFORE the opening <?php, so it is inline HTML
2764 * that the server prints verbatim to the browser;
2765 * - the same after a ?> that the file already had;
2766 * - the marked line ending a block comment opened on an earlier line and
2767 * opening another one at its end, with a statement in between, which
2768 * runs like any other statement.
2769 *
2770 * So the file is read the way PHP reads it, not the way the line looks. A
2771 * marked line is inert when every token touching it is a comment or
2772 * whitespace, which answers the three at once: inline HTML is not a comment,
2773 * and neither is a statement. The shape the guard was written for, code
2774 * BEFORE the marker, is the same question from the other side.
2775 *
2776 * The three shapes are in the harness as cells X2, X3 and X4 of
2777 * matriz-escondite-marcadores.sh, written out in full there. They are not
2778 * written out here on purpose: a literal payload in a shipped file is
2779 * signature surface for the scanners this plugin is read by, and a comment
2780 * is a bad place to pay for it.
2781 *
2782 * @since 2.11.10
2783 *
2784 * @param string $content Whole file content.
2785 * @return bool True when no marked line can run or print anything.
2786 */
2787 /**
2788 * The lines of a file that carry the original-value marker
2789 *
2790 * @since 2.11.10
2791 *
2792 * @param string $content Whole file content, newlines already normalised.
2793 * @return string[]
2794 */
2795 private function marked_lines_of( $content ) {
2796 $out = array();
2797
2798 foreach ( explode( "\n", $content ) as $text ) {
2799 if ( false !== strpos( $text, $this->wpconfig_original_marker ) ) {
2800 $out[] = $text;
2801 }
2802 }
2803
2804 return $out;
2805 }
2806
2807 /**
2808 * Whether what a marked line carries would still be harmless uncommented
2809 *
2810 * Only the part after the marker matters: what comes before it is answered by
2811 * the token pass, which refuses anything that is not comment or whitespace.
2812 * Here the question is what comes BACK when uncomment_original_constants()
2813 * removes the marker, so the body has to be a single define() and nothing
2814 * else, with at most a trailing line comment. Deliberately says nothing about
2815 * WHICH constant it is: asking that was the first version of this guard, and
2816 * it refused every define it did not recognise, which is exactly the case the
2817 * re-base exists for.
2818 *
2819 * @since 2.11.10
2820 *
2821 * @param string $line One line carrying the marker.
2822 * @return bool
2823 */
2824 private function marked_line_body_is_harmless( $line ) {
2825 $at = strpos( $line, $this->wpconfig_original_marker );
2826
2827 if ( false === $at ) {
2828 return true;
2829 }
2830
2831 $body = trim( substr( $line, $at + strlen( $this->wpconfig_original_marker ) ) );
2832
2833 if ( '' === $body ) {
2834 return true;
2835 }
2836
2837 // Tokenised as PHP so the trailing comment, the strings and the nesting
2838 // are read the way PHP reads them and not with a regular expression.
2839 $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.
2840
2841 if ( empty( $tokens ) ) {
2842 return false;
2843 }
2844
2845 $statements = 0;
2846 $depth = 0;
2847
2848 foreach ( $tokens as $token ) {
2849 $type = is_array( $token ) ? $token[0] : $token;
2850
2851 if ( in_array( $type, array( T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
2852 continue;
2853 }
2854
2855 if ( '(' === $type ) {
2856 $depth++;
2857 continue;
2858 }
2859
2860 if ( ')' === $type ) {
2861 $depth--;
2862 continue;
2863 }
2864
2865 // A semicolon at the top level closes a statement. More than one, or
2866 // anything after the first, means the line carries something else.
2867 if ( ';' === $type && 0 === $depth ) {
2868 $statements++;
2869 continue;
2870 }
2871
2872 if ( $statements > 0 ) {
2873 return false;
2874 }
2875 }
2876
2877 return ( $statements <= 1 );
2878 }
2879
2880 private function marked_lines_are_inert( $content ) {
2881 $content = str_replace( "\r\n", "\n", (string) $content );
2882 $marker = $this->wpconfig_original_marker;
2883
2884 if ( '' === $content || false === strpos( $content, $marker ) ) {
2885 return true;
2886 }
2887
2888 $marked = array();
2889
2890 foreach ( explode( "\n", $content ) as $index => $text ) {
2891 if ( false !== strpos( $text, $marker ) ) {
2892 $marked[ $index + 1 ] = true;
2893 }
2894 }
2895
2896 // Lenient on purpose (no TOKEN_PARSE): a tampered file still has to be
2897 // read, and a file that cannot be tokenised is never adopted.
2898 // 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.
2899 $tokens = @token_get_all( $content );
2900
2901 if ( empty( $tokens ) ) {
2902 return false;
2903 }
2904
2905 /*
2906 * And the other half of the question, which the first token version left
2907 * out: a marked line is a COMMENTED OUT value, and uncommenting it is what
2908 * the feature exists for, so "runs nothing today" is not enough. Anything
2909 * sharing the line after the define comes back with it. The shape is real
2910 * and needs no attacker: comment_existing_constants() takes a define and
2911 * everything on its line, so
2912 * define( 'WP_DEBUG', false ); @ini_set( 'display_errors', 0 );
2913 * is commented whole, and re-basing it would adopt as approved something
2914 * that runs the moment the value is restored. The old rule refused this
2915 * too, but along with every define whose NAME it did not recognise, which
2916 * is what left the function unable to act at all. Found by the third cross
2917 * review of 2.11.10.
2918 */
2919 foreach ( $this->marked_lines_of( $content ) as $text ) {
2920 if ( ! $this->marked_line_body_is_harmless( $text ) ) {
2921 return false;
2922 }
2923 }
2924
2925 $inocuos = array( T_COMMENT, T_DOC_COMMENT, T_WHITESPACE );
2926 $linea = 1;
2927
2928 foreach ( $tokens as $token ) {
2929 $texto = is_array( $token ) ? $token[1] : $token;
2930 $tipo = is_array( $token ) ? $token[0] : null;
2931 $saltos = substr_count( $texto, "\n" );
2932 $desde = $linea;
2933 $hasta = $linea + $saltos;
2934
2935 /*
2936 * A token whose text ends in a newline puts nothing on the line that
2937 * newline opens. Counting it would make the "<?php\n" of every file
2938 * touch line 2 and refuse the legitimate case, which is what the
2939 * first version of this did.
2940 */
2941 $ultima = ( $saltos > 0 && "\n" === substr( $texto, -1 ) ) ? $hasta - 1 : $hasta;
2942 $linea = $hasta;
2943
2944 if ( null !== $tipo && in_array( $tipo, $inocuos, true ) ) {
2945 continue;
2946 }
2947
2948 for ( $l = $desde; $l <= $ultima; $l++ ) {
2949 if ( isset( $marked[ $l ] ) ) {
2950 return false;
2951 }
2952 }
2953 }
2954
2955 return true;
2956 }
2957
2958 private function is_vigilant_original_line( $line ) {
2959 if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) {
2960 return false;
2961 }
2962
2963 return 1 === preg_match(
2964 '/^[ \t]*' . preg_quote( $this->wpconfig_original_marker, '/' ) . self::harmless_define_pattern() . '[ \t]*(?:(?:\/\/|#(?!\[)).*)?$/',
2965 $line
2966 );
2967 }
2968
2969 /**
2970 * A define() that runs nothing but itself, as a regular expression fragment
2971 *
2972 * The name is one of the constants Vigilant has managed in any version. The
2973 * value is made only of literals (true, false, null, a number, a quoted
2974 * string with nothing to interpolate) and of ABSPATH, WP_CONTENT_DIR and
2975 * __DIR__, which is what a debug log path is usually built from, joined
2976 * with dots. No call, no variable, no backtick, no include.
2977 *
2978 * @since 2.11.5
2979 *
2980 * @return string Pattern without delimiters.
2981 */
2982 private static function harmless_define_pattern() {
2983 $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'
2984 . '|WP_POST_REVISIONS|AUTOSAVE_INTERVAL|EMPTY_TRASH_DAYS|WP_MEMORY_LIMIT|WP_MAX_MEMORY_LIMIT|WP_AUTO_UPDATE_CORE|CONCATENATE_SCRIPTS';
2985
2986 $value = '(?:(?i:true|false|null)|-?\d+|\'(?:[^\'\\\\]|\\\\.)*\'|"[^"\\\\$]*"|ABSPATH|WP_CONTENT_DIR|__DIR__)';
2987
2988 return 'define\s*\(\s*[\'"](?:' . $names . ')[\'"]\s*,\s*' . $value . '(?:\s*\.\s*' . $value . ')*\s*\)\s*;';
2989 }
2990
2991 /**
2992 * Whether a wp-config.php constants block can only be one Vigilant wrote
2993 *
2994 * Every version of generate_constants() has written the start marker on its
2995 * own line, then comments, blank lines and define() calls, bare or wrapped
2996 * in if ( ! defined() ), then the end marker on its own line. A block made
2997 * only of those lines runs nothing but the defines, whatever version wrote
2998 * it and whatever settings it was written with. One line of anything else,
2999 * or a PHP tag on any line, and the block is not taken.
3000 *
3001 * @since 2.11.5
3002 *
3003 * @param string $block Block from start marker to end marker, inclusive.
3004 * @param array $markers Start and end marker.
3005 * @return bool
3006 */
3007 private static function is_harmless_constants_block( $block, $markers ) {
3008 $lines = explode( "\n", str_replace( array( "\r\n", "\r" ), "\n", (string) $block ) );
3009
3010 if ( count( $lines ) < 2
3011 || rtrim( array_shift( $lines ), " \t" ) !== $markers[0]
3012 || ltrim( array_pop( $lines ), " \t" ) !== $markers[1]
3013 ) {
3014 return false;
3015 }
3016
3017 $define = self::harmless_define_pattern();
3018 $guarded = '/^if\s*\(\s*!\s*defined\s*\(\s*[\'"][A-Z_]+[\'"]\s*\)\s*\)\s*\{\s*' . $define . '\s*\}$/';
3019
3020 foreach ( $lines as $line ) {
3021 $line = trim( $line, " \t" );
3022
3023 if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) {
3024 return false;
3025 }
3026
3027 if ( '' === $line
3028 || 0 === strpos( $line, '//' )
3029 || preg_match( '/^' . $define . '$/', $line )
3030 || preg_match( $guarded, $line )
3031 ) {
3032 continue;
3033 }
3034
3035 return false;
3036 }
3037
3038 return true;
3039 }
3040
3041 /**
3042 * Take ownership of the blocks already on disk, once
3043 *
3044 * Fingerprints are recorded when Vigilant writes a block, which leaves every
3045 * block written before 2.11.5 without one. This records the blocks that can
3046 * be recognised as Vigilant's without having seen them written:
3047 *
3048 * - A .htaccess block, when it is exactly what Vigilant would write today
3049 * with the settings it has, the timestamp and the version apart. After an
3050 * update, maybe_sync_server_files() rewrites those blocks on the next
3051 * request, so the claim waits for it. Unless it has already failed: a
3052 * block Vigilant cannot rewrite is not going to start matching, and
3053 * waiting for it would keep the old rule for good.
3054 * - A wp-config.php constants block, when every line in it is a comment, a
3055 * blank line or a harmless define(). Nothing rewrites that block on an
3056 * update and its format has changed five times, so comparing it with
3057 * today's output would report every site that has not saved those
3058 * settings since. A line that could run anything is never accepted.
3059 *
3060 * A block that is not recognised stays in the hash and is reported as a
3061 * change, so the owner gets to look at it, and the activity log says why.
3062 * Nothing in the stored baseline is rewritten.
3063 *
3064 * Only where the shared files belong, a single site or the main site of a
3065 * network, because the expected blocks come from that site's settings. Until
3066 * it has run, normalize_critical_file() keeps the old rule on every site.
3067 *
3068 * @since 2.11.5
3069 */
3070 /**
3071 * The admin_init entry point of the claim, which does ask for an administrator
3072 *
3073 * admin-ajax.php fires admin_init before it decides who is asking
3074 * (wp-admin/admin-ajax.php:45), so without this an anonymous request chose
3075 * the moment the claim runs. Unlike its two neighbours in
3076 * init_cleanup_hooks(), which only drop the plugin's own copy out of the
3077 * database, the claim writes two network options, changes for the whole
3078 * network the rule normalize_critical_file() applies, and re-bases the
3079 * approved baseline.
3080 *
3081 * The gate lives here and not inside maybe_claim_owned_blocks() because the
3082 * scan calls that one directly and the scan runs from wp-cron, with no user:
3083 * putting the capability check inside left the claim unable to complete on
3084 * any site whose dashboard nobody opens, and until it completes the older,
3085 * permissive rule is the one in force, which is the hiding place 2.11.5 was
3086 * written to close. Found by the cross review of 2.11.10.
3087 *
3088 * @since 2.11.10
3089 */
3090 public function maybe_claim_owned_blocks_on_admin() {
3091 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
3092 return;
3093 }
3094
3095 $this->maybe_claim_owned_blocks();
3096 }
3097
3098 public function maybe_claim_owned_blocks() {
3099 if ( $this->owned_blocks_claimed() || ! Vigilante_Settings::owns_shared_files() ) {
3100 return;
3101 }
3102
3103 $sync_due = get_option( 'vigilante_server_files_pending' )
3104 || VIGILANTE_VERSION !== get_option( 'vigilante_server_files_version' );
3105
3106 if ( $sync_due && ! get_option( 'vigilante_server_files_retry_after' ) ) {
3107 return;
3108 }
3109
3110 $expected = null;
3111 $unclaimed = array();
3112
3113 foreach ( $this->critical_root_files as $filename ) {
3114 $full_path = $this->critical_file_path( $filename );
3115
3116 if ( false === $full_path ) {
3117 continue;
3118 }
3119
3120 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
3121
3122 if ( false === $content ) {
3123 // Unreadable right now: leave the claim open and try again later.
3124 return;
3125 }
3126
3127 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
3128 $is_config = 'wp-config.php' === $filename;
3129
3130 foreach ( ( $is_config ? $this->wpconfig_markers : $this->htaccess_markers ) as $markers ) {
3131 $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '/s';
3132
3133 if ( ! preg_match_all( $pattern, $content, $found ) ) {
3134 continue;
3135 }
3136
3137 foreach ( $found[0] as $block ) {
3138 if ( $is_config ) {
3139 $ours = self::is_harmless_constants_block( $block, $markers );
3140 } else {
3141 $expected = null === $expected ? $this->expected_htaccess_blocks() : $expected;
3142 $ours = isset( $expected[ $markers[0] ] )
3143 && self::comparable_block( $block ) === self::comparable_block( $expected[ $markers[0] ] );
3144 }
3145
3146 if ( $ours ) {
3147 self::remember_owned_block( $filename, $markers[0], $block, false );
3148 } else {
3149 $unclaimed[ $filename ] = $filename;
3150 }
3151 }
3152 }
3153
3154 // The commented-out originals are judged line by line at scan time.
3155 // Looking at them here only keeps the log entry below complete.
3156 if ( $is_config && preg_match_all( '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m', $content, $marked ) ) {
3157 foreach ( $marked[0] as $line ) {
3158 if ( ! $this->is_vigilant_original_line( $line ) ) {
3159 $unclaimed[ $filename ] = $filename;
3160 }
3161 }
3162 }
3163 }
3164
3165 update_site_option( self::OWNED_BLOCKS_CLAIM_OPTION, self::OWNED_BLOCKS_CLAIMED );
3166
3167 // With the claim in place normalize uses the new rule, so a file nobody
3168 // touched whose only difference is an original line the old rule dropped
3169 // would read as changed. Re-base those, and only those, once.
3170 $this->rebase_original_line_shift();
3171
3172 if ( $unclaimed && $this->activity_log ) {
3173 $this->activity_log->log(
3174 'file',
3175 'critical_file_unrecognized_block',
3176 sprintf(
3177 /* translators: %s: comma-separated file names, such as wp-config.php or .htaccess. */
3178 __( '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' ),
3179 implode( ', ', $unclaimed )
3180 ),
3181 array( 'files' => array_values( $unclaimed ) ),
3182 'warning'
3183 );
3184 }
3185 }
3186
3187 /**
3188 * Re-base the critical files whose only change is a newly kept original line
3189 *
3190 * Until 2.11.5 the hash left out every [VIGILANTE_ORIGINAL] line; from 2.11.5
3191 * it keeps the ones whose value is not a plain constant define, which is the
3192 * right thing for the hash but moves it on a file nobody edited: the stored
3193 * baseline was taken under the old rule, and nothing re-bases wp-config.php on
3194 * an update (maybe_sync_server_files() only rewrites the .htaccess). So the
3195 * first scan after updating would report wp-config.php as changed.
3196 *
3197 * This runs once, in the same pass that claims the blocks. For each file it
3198 * re-bases to the new hash only when the baseline still matches the file with
3199 * every original line dropped, which means the blocks are exactly the approved
3200 * ones and the sole difference is those lines, the user's own commented-out
3201 * defines. A block that was edited or planted does not match with the lines
3202 * dropped, so it is left to be reported: this closes the false positive
3203 * without adopting anything that was hidden before.
3204 *
3205 * @since 2.11.5
3206 */
3207 private function rebase_original_line_shift() {
3208 $baseline = $this->get_critical_files_baseline();
3209 $changed = false;
3210
3211 foreach ( $this->critical_root_files as $filename ) {
3212 $full_path = $this->critical_file_path( $filename );
3213
3214 if ( false === $full_path || empty( $baseline[ $filename ]['hash'] ) ) {
3215 continue;
3216 }
3217
3218 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
3219
3220 if ( false === $content ) {
3221 continue;
3222 }
3223
3224 /*
3225 * Never re-base a file that carries a marked line which is not
3226 * wholly a comment. The test below only establishes that the
3227 * difference lies in lines carrying the marker, and the old rule
3228 * dropped the WHOLE line, so a line with a statement in front of the
3229 * marker satisfies it (cell X1 of matriz-escondite-marcadores.sh,
3230 * where the shape is written out): re-basing would write that line
3231 * into the approved baseline and rewrite the stored content, so the
3232 * diff would stop showing it. Adopting as approved what the previous rule
3233 * hid is the one thing an integrity scanner must never do, and the
3234 * log entry of maybe_claim_owned_blocks() already promises the
3235 * opposite ("the scan reports it as a change for you to review").
3236 * Those files are left to be reported. Found by the file-by-file
3237 * review of 2.11.10.
3238 *
3239 * What counts as "wholly a comment" is decided by reading the file
3240 * as PHP reads it, not by the shape of the line: see
3241 * marked_lines_are_inert(). A line that is not recognised is not the
3242 * same thing as a line that can run something, and the first
3243 * wording of this guard confused the two.
3244 */
3245 if ( ! $this->marked_lines_are_inert( $content ) ) {
3246 continue;
3247 }
3248
3249 $current = md5( $this->normalize_critical_file( $filename, $content ) );
3250
3251 // Already in step, or a real change to something other than the
3252 // original lines: nothing to re-base here.
3253 if ( $baseline[ $filename ]['hash'] === $current
3254 || $baseline[ $filename ]['hash'] !== md5( $this->normalize_critical_file( $filename, $content, true ) )
3255 ) {
3256 continue;
3257 }
3258
3259 $normalized = $this->normalize_critical_file( $filename, $content );
3260 $baseline[ $filename ]['hash'] = $current;
3261 $baseline[ $filename ]['size'] = strlen( $content );
3262 $baseline[ $filename ]['content'] = $this->baseline_content( $filename, $normalized );
3263 $baseline[ $filename ]['updated'] = time();
3264 $changed = true;
3265 }
3266
3267 if ( $changed ) {
3268 $this->write_baseline( $baseline );
3269 }
3270 }
3271
3272 /**
3273 * The .htaccess blocks Vigilant would write today, keyed by start marker
3274 *
3275 * @since 2.11.5
3276 *
3277 * @return array
3278 */
3279 private function expected_htaccess_blocks() {
3280 $settings = $this->settings ? $this->settings : new Vigilante_Settings();
3281
3282 $classes = array(
3283 'Vigilante_Htaccess_Protection' => 'class-htaccess-protection.php',
3284 'Vigilante_Security_Headers' => 'class-security-headers.php',
3285 );
3286
3287 foreach ( $classes as $class => $file ) {
3288 if ( ! class_exists( $class ) ) {
3289 require_once VIGILANTE_INCLUDES_DIR . $file;
3290 }
3291 }
3292
3293 $headers = new Vigilante_Security_Headers( $settings );
3294
3295 return array(
3296 Vigilante_Htaccess_Protection::MARKER_START => ( new Vigilante_Htaccess_Protection( $settings ) )->generate_rules(),
3297 Vigilante_Security_Headers::MARKER_START => Vigilante_Security_Headers::MARKER_START . "\n" . $headers->generate_rules_content() . "\n" . Vigilante_Security_Headers::MARKER_END,
3298 );
3299 }
3300
3301 /**
3302 * A .htaccess block with the parts that change on every write evened out
3303 *
3304 * Two blocks Vigilant wrote with the same settings differ only in the time
3305 * they were generated and, across an update, in the version the firewall
3306 * block names. Everything else has to be identical for the claim to take
3307 * the block.
3308 *
3309 * @since 2.11.5
3310 *
3311 * @param string $block Block from start marker to end marker, inclusive.
3312 * @return string
3313 */
3314 private static function comparable_block( $block ) {
3315 $block = str_replace( array( "\r\n", "\r" ), "\n", (string) $block );
3316 $block = preg_replace( '/^# Generated: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/m', '# Generated:', $block );
3317 $block = preg_replace( '/^# Vigilante for WordPress - Firewall v[0-9][0-9A-Za-z.\-]*$/m', '# Vigilante for WordPress - Firewall v', $block );
3318
3319 return rtrim( $block, "\n" );
3320 }
3321
3322 /**
3323 * Get stored baseline hashes for critical files
3324 *
3325 * @return array Associative array keyed by filename.
3326 */
3327 public function get_critical_files_baseline() {
3328 return $this->read_baseline();
3329 }
3330
3331 /**
3332 * Update baseline hash for a single critical file
3333 *
3334 * Called by wp-config and htaccess writers after Vigilante modifies
3335 * the file, so the next scan does not flag the change as suspicious.
3336 *
3337 * @param string $filename File name relative to ABSPATH (e.g. 'wp-config.php').
3338 * @return bool True on success.
3339 */
3340 public function update_critical_file_baseline( $filename ) {
3341 $full_path = $this->critical_file_path( $filename );
3342
3343 if ( false === $full_path ) {
3344 return false;
3345 }
3346
3347 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
3348 if ( false === $content ) {
3349 return false;
3350 }
3351
3352 $normalized = $this->normalize_critical_file( $filename, $content );
3353
3354 $baseline = $this->get_critical_files_baseline();
3355
3356 /*
3357 * No guard here, and there was one for a few hours during 2.11.4 that
3358 * had to come out. It refused to rewrite the record when the stored hash
3359 * no longer matched the file, meant to stop a write of ours from
3360 * approving somebody else's pending edit. Two things were wrong with it,
3361 * both measured on 10 sep 2026 by a third cross review:
3362 *
3363 * - This is also the Approve button (Vigilante_Admin_Ajax::
3364 * ajax_approve_critical_file). A moved hash is exactly the state in
3365 * which Approve is pressed, so the guard made Approve fail every time
3366 * and the warning could never be closed.
3367 * - Its premise, "our own write cannot move the normalized hash", holds
3368 * for the block and not for the rest of what the writers do.
3369 * comment_existing_constants() turns a define() into a
3370 * [VIGILANTE_ORIGINAL] line that normalize_critical_file() leaves as
3371 * an empty line, and remove_old_rules() deletes legacy .htaccess blocks
3372 * that normalize_critical_file() does not know. Both move the hash, so
3373 * the guard would have raised a false "file modified" after Vigilant's
3374 * own work, on sites that updated.
3375 *
3376 * The real fix is to know what the hash was before WE touched the file:
3377 * the writers capture it and pass it along vigilante_critical_file_
3378 * written, and this compares against that instead of against the
3379 * record. Until then this behaves as it always has, which does mean a
3380 * write of ours can adopt a third-party edit that was pending review.
3381 * That is pre-existing, and written down in the roadmap.
3382 */
3383 $baseline[ $filename ] = array(
3384 'hash' => md5( $normalized ),
3385 'size' => strlen( $content ),
3386 'content' => $this->baseline_content( $filename, $normalized ),
3387 'updated' => time(),
3388 );
3389
3390 return $this->write_baseline( $baseline );
3391 }
3392
3393 /**
3394 * Regenerate baseline for all critical files
3395 *
3396 * Used by the admin UI button and the 1.14.0 migration.
3397 *
3398 * @return array Updated baseline data.
3399 */
3400 public function regenerate_all_baselines() {
3401 $baseline = array();
3402
3403 foreach ( $this->critical_root_files as $filename ) {
3404 $full_path = $this->critical_file_path( $filename );
3405
3406 if ( false === $full_path ) {
3407 continue;
3408 }
3409
3410 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
3411 if ( false === $content ) {
3412 continue;
3413 }
3414
3415 $normalized = $this->normalize_critical_file( $filename, $content );
3416 $baseline[ $filename ] = array(
3417 'hash' => md5( $normalized ),
3418 'size' => strlen( $content ),
3419 'content' => $this->baseline_content( $filename, $normalized ),
3420 'updated' => time(),
3421 );
3422 }
3423
3424 $this->write_baseline( $baseline );
3425
3426 return $baseline;
3427 }
3428
3429 /**
3430 * Get core checksums from WordPress.org API
3431 *
3432 * @param bool $force_refresh Skip the cached copy and ask wp.org again.
3433 * @return array|WP_Error Checksums or error.
3434 */
3435 private function get_core_checksums( $force_refresh = false ) {
3436 $locale = get_locale();
3437 $version = $this->wp_version;
3438
3439 // Check cache first
3440 $cache_key = 'vigilante_core_checksums_' . md5( $version . $locale );
3441
3442 if ( $force_refresh ) {
3443 delete_transient( $cache_key );
3444 }
3445
3446 $cached = get_transient( $cache_key );
3447 if ( false !== $cached ) {
3448 return $cached;
3449 }
3450
3451 // Fetch from WordPress.org
3452 $url = sprintf(
3453 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s',
3454 $version,
3455 $locale
3456 );
3457
3458 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
3459
3460 if ( is_wp_error( $response ) ) {
3461 return $response;
3462 }
3463
3464 $body = json_decode( wp_remote_retrieve_body( $response ), true );
3465
3466 if ( empty( $body['checksums'] ) ) {
3467 return new WP_Error( 'no_checksums', __( 'Could not retrieve WordPress core checksums', 'vigilante' ) );
3468 }
3469
3470 $checksums = $body['checksums'];
3471
3472 // Handle nested format: checksums keyed under version string (WP 6.9+)
3473 if ( isset( $checksums[ $version ] ) && is_array( $checksums[ $version ] ) ) {
3474 $checksums = $checksums[ $version ];
3475 }
3476
3477 // Cache for 24 hours
3478 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
3479
3480 return $checksums;
3481 }
3482
3483 /**
3484 * Scan plugins for modifications
3485 *
3486 * @return array Scan results.
3487 */
3488 private function scan_plugins() {
3489 $results = array(
3490 'scanned' => 0,
3491 'ok' => 0,
3492 'modified' => array(),
3493 'suspicious' => array(),
3494 'extra' => array(),
3495 'errors' => array(),
3496 );
3497
3498 // Get all installed plugins
3499 if ( ! function_exists( 'get_plugins' ) ) {
3500 require_once ABSPATH . 'wp-admin/includes/plugin.php';
3501 }
3502
3503 $plugins = get_plugins();
3504
3505 foreach ( $plugins as $plugin_file => $plugin_data ) {
3506 // Check time limit
3507 if ( $this->is_time_exceeded() ) {
3508 break;
3509 }
3510
3511 $plugin_slug = dirname( $plugin_file );
3512
3513 // Skip single-file plugins
3514 if ( '.' === $plugin_slug ) {
3515 continue;
3516 }
3517
3518 // Skip slugs in their post-update grace window: wp.org may still be
3519 // publishing the new version's checksums, so a scheduled scan here
3520 // would raise benign "modified/extra" noise. The dedicated post-update
3521 // verifier (vigilante_fi_postupdate_verify) handles these instead.
3522 if ( $this->in_post_update_grace( 'plugin', $plugin_slug ) ) {
3523 continue;
3524 }
3525
3526 // Get checksums from WordPress.org
3527 $version = $plugin_data['Version'] ?? '';
3528 $checksums = $this->get_plugin_checksums( $plugin_slug, $version );
3529
3530 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
3531
3532 $plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug;
3533
3534 // Check known files against checksums (only if available)
3535 if ( $has_checksums ) {
3536 foreach ( $checksums as $file => $expected_hash ) {
3537 // Check time limit inside inner loop too
3538 if ( $this->is_time_exceeded() ) {
3539 break 2; // Break both loops
3540 }
3541
3542 $file_path = $plugin_dir . '/' . $file;
3543
3544 // Skip excluded paths
3545 if ( $this->is_path_excluded( $file_path ) ) {
3546 continue;
3547 }
3548
3549 // Skip excluded extensions
3550 if ( $this->is_extension_excluded( $file_path ) ) {
3551 continue;
3552 }
3553
3554 // Skip known false positives (e.g. readme.txt, readme.md)
3555 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
3556 continue;
3557 }
3558
3559 $results['scanned']++;
3560
3561 if ( ! file_exists( $file_path ) ) {
3562 continue; // Some files might not be installed
3563 }
3564
3565 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
3566 $results['modified'][] = array(
3567 'file' => 'plugins/' . $plugin_slug . '/' . $file,
3568 'type' => 'plugin',
3569 'plugin' => $plugin_data['Name'],
3570 'expected_hash' => $this->expected_hash_label( $expected_hash ),
3571 'actual_hash' => md5_file( $file_path ),
3572 );
3573 } else {
3574 $results['ok']++;
3575 }
3576 }
3577 } // end if $has_checksums
3578
3579 // Detect extra/suspicious files
3580 // With checksums: finds files not in the original distribution
3581 // Without checksums: scans ALL plugin files but only flags suspicious patterns
3582 if ( ! $this->is_time_exceeded() ) {
3583 $known_files = $has_checksums ? $checksums : array();
3584 $suspicious_only = ! $has_checksums; // Without checksums, only report files with suspicious code
3585 $extra_results = $this->detect_extra_files( $plugin_dir, $known_files, 'plugin', $plugin_data['Name'], $suspicious_only );
3586 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
3587 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
3588 }
3589 }
3590
3591 return $results;
3592 }
3593
3594 /**
3595 * Whether a file on disk matches a hash WordPress.org publishes for it.
3596 *
3597 * The wp.org checksums JSON gives, per file, an md5 (and usually a sha256)
3598 * that may be a single string OR an array of strings: a file whose content
3599 * differs across the re-tagged zips that one checksums file covers (e.g. a
3600 * release that shares its checksums file with a beta) gets every valid hash
3601 * listed as an array. The old `$actual !== $expected` comparison evaluated a
3602 * 32-char string against an array as unequal unconditionally, so those files
3603 * were always reported as "modified" even when the on-disk hash was one of
3604 * the published ones. Match against membership, and accept either md5 or
3605 * sha256, so the comparison is correct and strictly stronger than md5-only.
3606 *
3607 * Robust to both shapes: the new record array( 'md5' => ..., 'sha256' => ... )
3608 * and a legacy cached value (a bare md5 string or array), so a transient
3609 * cached by an older version still compares correctly until it expires.
3610 *
3611 * @param string $file_path Absolute path to the file on disk.
3612 * @param array|string $expected Record array, or a legacy md5 string|array.
3613 * @return bool True when the file matches a published hash.
3614 */
3615 private function hash_matches_published( $file_path, $expected ) {
3616 $md5 = $expected;
3617 $sha = null;
3618 if ( is_array( $expected ) && ( array_key_exists( 'md5', $expected ) || array_key_exists( 'sha256', $expected ) ) ) {
3619 $md5 = isset( $expected['md5'] ) ? $expected['md5'] : null;
3620 $sha = isset( $expected['sha256'] ) ? $expected['sha256'] : null;
3621 }
3622
3623 if ( null !== $md5 && in_array( md5_file( $file_path ), (array) $md5, true ) ) {
3624 return true;
3625 }
3626 if ( ! empty( $sha ) && in_array( hash_file( 'sha256', $file_path ), (array) $sha, true ) ) {
3627 return true;
3628 }
3629
3630 // Fallback: some hosts and deploy pipelines rewrite text files on disk
3631 // (prepend a UTF-8 BOM, or convert LF line endings to CRLF) without
3632 // changing a single line of code. That alters the raw bytes, so the
3633 // md5/sha256 stops matching WordPress.org even though the file is
3634 // intact, which surfaced as false "modified file" alerts. Retry the
3635 // comparison against a normalized copy (BOM stripped, CRLF/CR collapsed
3636 // to LF) for text files only, so a genuine code change is still caught.
3637 if ( is_string( $file_path ) && '' !== $file_path && $this->is_text_file( $file_path ) && is_readable( $file_path ) ) {
3638 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read for hashing, not remote.
3639 $content = file_get_contents( $file_path );
3640 if ( false !== $content ) {
3641 if ( "\xEF\xBB\xBF" === substr( $content, 0, 3 ) ) {
3642 $content = substr( $content, 3 );
3643 }
3644 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
3645 if ( null !== $md5 && in_array( md5( $content ), (array) $md5, true ) ) {
3646 return true;
3647 }
3648 if ( ! empty( $sha ) && in_array( hash( 'sha256', $content ), (array) $sha, true ) ) {
3649 return true;
3650 }
3651 }
3652 }
3653
3654 return false;
3655 }
3656
3657 /**
3658 * Human-readable expected-hash value for a modified-file result record.
3659 *
3660 * The published md5 may be a string or an array; flatten it for storage.
3661 *
3662 * @param array|string $expected Record array or legacy md5 string|array.
3663 * @return string
3664 */
3665 private function expected_hash_label( $expected ) {
3666 $md5 = ( is_array( $expected ) && array_key_exists( 'md5', $expected ) ) ? $expected['md5'] : $expected;
3667 if ( is_array( $md5 ) ) {
3668 return implode( ', ', array_map( 'strval', $md5 ) );
3669 }
3670 return (string) $md5;
3671 }
3672
3673 /**
3674 * Whether a plugin/theme slug is inside its post-update grace window.
3675 *
3676 * Set by on_upgrade_complete() right after WordPress finishes updating a
3677 * plugin or theme. During the window the checksum cache is bypassed (so a
3678 * stale manifest cached during wp.org's propagation lag is never reused) and
3679 * the scheduled scan skips the slug (the dedicated post-update verifier
3680 * handles it instead), which is what stops the "files don't match
3681 * WordPress.org" false positives right after an update.
3682 *
3683 * @param string $type 'plugin' or 'theme'.
3684 * @param string $slug Slug.
3685 * @return bool
3686 */
3687 private function in_post_update_grace( $type, $slug ) {
3688 return (bool) get_transient( 'vigilante_fi_grace_' . $type . '_' . md5( $slug ) );
3689 }
3690
3691 /**
3692 * Get plugin checksums from WordPress.org
3693 *
3694 * @param string $slug Plugin slug.
3695 * @param string $version Plugin version.
3696 * @return array|WP_Error|string
3697 */
3698 private function get_plugin_checksums( $slug, $version ) {
3699 $cache_key = 'vigilante_plugin_checksums_' . md5( $slug . $version );
3700
3701 // During the post-update grace window, bypass the cache entirely so a
3702 // manifest cached while wp.org was still propagating the new version's
3703 // checksums can never be reused. Fetch fresh and do not write it back.
3704 $grace = $this->in_post_update_grace( 'plugin', $slug );
3705
3706 if ( ! $grace ) {
3707 $cached = get_transient( $cache_key );
3708 if ( false !== $cached ) {
3709 return $cached;
3710 }
3711 }
3712
3713 $url = sprintf(
3714 'https://downloads.wordpress.org/plugin-checksums/%s/%s.json',
3715 $slug,
3716 $version
3717 );
3718
3719 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
3720
3721 if ( is_wp_error( $response ) ) {
3722 return $response;
3723 }
3724
3725 $status = wp_remote_retrieve_response_code( $response );
3726 if ( 200 !== $status ) {
3727 // Cache "not found" to avoid repeated requests (never during grace).
3728 if ( ! $grace ) {
3729 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
3730 }
3731 return 'not_found';
3732 }
3733
3734 $body = json_decode( wp_remote_retrieve_body( $response ), true );
3735
3736 if ( empty( $body['files'] ) ) {
3737 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
3738 }
3739
3740 // Store the full per-file record (md5 + sha256). Either value can be a
3741 // string or an array of strings; hash_matches_published() handles both.
3742 $checksums = array();
3743 foreach ( $body['files'] as $file => $data ) {
3744 $checksums[ $file ] = array(
3745 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
3746 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
3747 );
3748 }
3749
3750 // Cache for 24 hours (never during grace, to avoid persisting a manifest
3751 // wp.org may still be regenerating).
3752 if ( ! $grace ) {
3753 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
3754 }
3755
3756 return $checksums;
3757 }
3758
3759 /**
3760 * Scan themes for modifications
3761 *
3762 * @return array Scan results.
3763 */
3764 private function scan_themes() {
3765 $results = array(
3766 'scanned' => 0,
3767 'ok' => 0,
3768 'modified' => array(),
3769 'suspicious' => array(),
3770 'extra' => array(),
3771 'errors' => array(),
3772 );
3773
3774 $themes = wp_get_themes();
3775
3776 foreach ( $themes as $theme_slug => $theme ) {
3777 // Check time limit
3778 if ( $this->is_time_exceeded() ) {
3779 break;
3780 }
3781
3782 // Skip slugs in their post-update grace window (see scan_plugins()).
3783 if ( $this->in_post_update_grace( 'theme', $theme_slug ) ) {
3784 continue;
3785 }
3786
3787 $version = $theme->get( 'Version' );
3788 $checksums = $this->get_theme_checksums( $theme_slug, $version );
3789
3790 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
3791
3792 $theme_dir = $theme->get_stylesheet_directory();
3793
3794 // Check known files against checksums (only if available)
3795 if ( $has_checksums ) {
3796 foreach ( $checksums as $file => $expected_hash ) {
3797 // Check time limit inside inner loop too
3798 if ( $this->is_time_exceeded() ) {
3799 break 2; // Break both loops
3800 }
3801
3802 $file_path = $theme_dir . '/' . $file;
3803
3804 // Skip excluded paths
3805 if ( $this->is_path_excluded( $file_path ) ) {
3806 continue;
3807 }
3808
3809 // Skip excluded extensions
3810 if ( $this->is_extension_excluded( $file_path ) ) {
3811 continue;
3812 }
3813
3814 // Skip known false positives (e.g. readme.txt, readme.md)
3815 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
3816 continue;
3817 }
3818
3819 $results['scanned']++;
3820
3821 if ( ! file_exists( $file_path ) ) {
3822 continue;
3823 }
3824
3825 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
3826 $results['modified'][] = array(
3827 'file' => 'themes/' . $theme_slug . '/' . $file,
3828 'type' => 'theme',
3829 'theme' => $theme->get( 'Name' ),
3830 'expected_hash' => $this->expected_hash_label( $expected_hash ),
3831 'actual_hash' => md5_file( $file_path ),
3832 );
3833 } else {
3834 $results['ok']++;
3835 }
3836 }
3837 } // end if $has_checksums
3838
3839 // Detect extra/suspicious files
3840 if ( ! $this->is_time_exceeded() ) {
3841 $known_files = $has_checksums ? $checksums : array();
3842 $suspicious_only = ! $has_checksums;
3843 $extra_results = $this->detect_extra_files( $theme_dir, $known_files, 'theme', $theme->get( 'Name' ), $suspicious_only );
3844 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
3845 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
3846 }
3847 }
3848
3849 return $results;
3850 }
3851
3852 /**
3853 * Get theme checksums from WordPress.org
3854 *
3855 * @param string $slug Theme slug.
3856 * @param string $version Theme version.
3857 * @return array|WP_Error
3858 */
3859 private function get_theme_checksums( $slug, $version ) {
3860 $cache_key = 'vigilante_theme_checksums_' . md5( $slug . $version );
3861
3862 // Bypass the cache during the post-update grace window (see plugin path).
3863 $grace = $this->in_post_update_grace( 'theme', $slug );
3864
3865 if ( ! $grace ) {
3866 $cached = get_transient( $cache_key );
3867 if ( false !== $cached ) {
3868 return $cached;
3869 }
3870 }
3871
3872 $url = sprintf(
3873 'https://downloads.wordpress.org/theme-checksums/%s/%s.json',
3874 $slug,
3875 $version
3876 );
3877
3878 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
3879
3880 if ( is_wp_error( $response ) ) {
3881 return $response;
3882 }
3883
3884 $status = wp_remote_retrieve_response_code( $response );
3885 if ( 200 !== $status ) {
3886 // Cache "not found" to avoid repeated requests (never during grace).
3887 if ( ! $grace ) {
3888 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
3889 }
3890 return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) );
3891 }
3892
3893 $body = json_decode( wp_remote_retrieve_body( $response ), true );
3894
3895 if ( empty( $body['files'] ) ) {
3896 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
3897 }
3898
3899 // Store the full per-file record (md5 + sha256), either of which may be a
3900 // string or an array; hash_matches_published() handles both shapes.
3901 $checksums = array();
3902 foreach ( $body['files'] as $file => $data ) {
3903 $checksums[ $file ] = array(
3904 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
3905 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
3906 );
3907 }
3908
3909 // Cache for 24 hours (never during grace).
3910 if ( ! $grace ) {
3911 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
3912 }
3913
3914 return $checksums;
3915 }
3916
3917 /**
3918 * Scan uploads directory for suspicious files
3919 *
3920 * @return array Array with 'suspicious' and 'extra' sub-arrays.
3921 */
3922 private function scan_uploads() {
3923 $found = array(
3924 'suspicious' => array(),
3925 'extra' => array(),
3926 );
3927 $upload_dir = wp_upload_dir();
3928 $base_dir = $upload_dir['basedir'];
3929 $max_files = 10000; // Increased limit for thorough scanning
3930 $files_checked = 0;
3931
3932 // Executable extensions that should never be in uploads
3933 $dangerous_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
3934
3935 if ( ! is_dir( $base_dir ) ) {
3936 return $found;
3937 }
3938
3939 try {
3940 $iterator = new RecursiveIteratorIterator(
3941 new RecursiveDirectoryIterator( $base_dir, RecursiveDirectoryIterator::SKIP_DOTS ),
3942 RecursiveIteratorIterator::LEAVES_ONLY
3943 );
3944
3945 foreach ( $iterator as $file ) {
3946 // Check global time limit
3947 if ( $this->is_time_exceeded() ) {
3948 break;
3949 }
3950
3951 // Check file limit
3952 $files_checked++;
3953 if ( $files_checked > $max_files ) {
3954 break;
3955 }
3956
3957 $file_path = $file->getPathname();
3958 $basename = basename( $file_path );
3959 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
3960 $relative = str_replace( ABSPATH, '', $file_path );
3961
3962 // Skip excluded paths
3963 if ( $this->is_path_excluded( $file_path ) ) {
3964 continue;
3965 }
3966
3967 // 1. Check for PHP files in uploads (most important security check)
3968 if ( in_array( $extension, $dangerous_extensions, true ) ) {
3969 // Silence-is-golden placeholders are dropped by WordPress and many
3970 // plugins into upload subfolders to block directory listings.
3971 // Whitelist by content so an attacker can't bypass the rule with
3972 // a payload named index.php.
3973 if ( $this->is_silence_golden_file( $file_path ) ) {
3974 continue;
3975 }
3976
3977 $reason = __( 'PHP file found in uploads directory', 'vigilante' );
3978
3979 // Scan content for specific suspicious patterns
3980 if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
3981 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
3982 $pattern = $this->detect_suspicious_pattern( $content );
3983 if ( $pattern ) {
3984 /* translators: %s: Suspicious pattern found */
3985 $reason = sprintf( __( 'PHP in uploads with suspicious code: %s', 'vigilante' ), $pattern );
3986 }
3987 }
3988
3989 $found['suspicious'][] = array(
3990 'file' => $relative,
3991 'type' => 'php_in_uploads',
3992 'reason' => $reason,
3993 );
3994 continue;
3995 }
3996
3997 // 2. Check for double extensions (image.php.jpg, file.phtml.png)
3998 if ( preg_match( '/\.(' . implode( '|', $dangerous_extensions ) . ')\.[a-z]{2,4}$/i', $basename ) ) {
3999 $found['suspicious'][] = array(
4000 'file' => $relative,
4001 'type' => 'double_extension',
4002 'reason' => __( 'Double extension detected (possible disguised executable)', 'vigilante' ),
4003 );
4004 continue;
4005 }
4006
4007 // 3. Check for .htaccess files in uploads
4008 // Read content to classify: dangerous rules = suspicious, protective rules = extra
4009 if ( '.htaccess' === $basename ) {
4010 $htaccess_result = $this->classify_htaccess_in_uploads( $file_path, $relative );
4011 $found[ $htaccess_result['category'] ][] = $htaccess_result['item'];
4012 }
4013 }
4014 } catch ( Exception $e ) {
4015 // Ignore iterator errors
4016 }
4017
4018 return $found;
4019 }
4020
4021 /**
4022 * Classify a .htaccess file found in uploads directory
4023 *
4024 * Reads the file content to determine if it contains dangerous rules
4025 * (enabling PHP execution, rewriting to executables) or protective rules
4026 * (deny access, disable indexes). Dangerous = suspicious, protective = extra.
4027 *
4028 * @param string $file_path Absolute file path.
4029 * @param string $relative Relative file path for display.
4030 * @return array Array with 'category' ('suspicious' or 'extra') and 'item' data.
4031 */
4032 private function classify_htaccess_in_uploads( $file_path, $relative ) {
4033 $content = '';
4034
4035 if ( filesize( $file_path ) < 65536 ) { // Only read files < 64KB
4036 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4037 }
4038
4039 // If we can't read it or it's empty, treat as suspicious (unknown)
4040 if ( empty( trim( $content ) ) ) {
4041 return array(
4042 'category' => 'suspicious',
4043 'item' => array(
4044 'file' => $relative,
4045 'type' => 'htaccess_in_uploads',
4046 'reason' => __( '.htaccess file in uploads directory (empty or unreadable)', 'vigilante' ),
4047 ),
4048 );
4049 }
4050
4051 // Dangerous patterns: rules that enable code execution or rewrite to executables
4052 $dangerous_patterns = array(
4053 '/AddHandler\s+.*(php|cgi|pl|py)/i' => 'AddHandler enabling script execution',
4054 '/AddType\s+application\/x-httpd-php/i' => 'AddType enabling PHP execution',
4055 '/SetHandler\s+.*(php|cgi)/i' => 'SetHandler enabling script execution',
4056 '/php_flag\s+engine\s+on/i' => 'PHP engine enabled',
4057 '/php_admin_flag\s+engine\s+on/i' => 'PHP admin engine enabled',
4058 '/RewriteRule\s+.*\.(php|phtml|phar)/i' => 'Rewrite rule targeting PHP files',
4059 '/auto_prepend_file/i' => 'auto_prepend_file directive',
4060 '/auto_append_file/i' => 'auto_append_file directive',
4061 );
4062
4063 foreach ( $dangerous_patterns as $pattern => $label ) {
4064 if ( preg_match( $pattern, $content ) ) {
4065 return array(
4066 'category' => 'suspicious',
4067 'item' => array(
4068 'file' => $relative,
4069 'type' => 'htaccess_in_uploads',
4070 /* translators: %s: Dangerous rule description */
4071 'reason' => sprintf( __( '.htaccess with dangerous rule: %s', 'vigilante' ), $label ),
4072 ),
4073 );
4074 }
4075 }
4076
4077 // Identify what protective/benign rules it contains for informational display
4078 $found_rules = array();
4079
4080 $benign_patterns = array(
4081 '/Deny\s+from\s+all/i' => 'Deny from all',
4082 '/Require\s+all\s+denied/i' => 'Require all denied',
4083 '/Options\s+.*-Indexes/i' => 'Options -Indexes',
4084 '/Header\s+set/i' => 'Header rules',
4085 '/ExpiresActive/i' => 'Expires/cache rules',
4086 '/RewriteEngine/i' => 'Rewrite rules',
4087 '/FilesMatch/i' => 'FilesMatch rules',
4088 '/ForceType\s+application\/octet/i' => 'ForceType (force download)',
4089 );
4090
4091 foreach ( $benign_patterns as $pattern => $label ) {
4092 if ( preg_match( $pattern, $content ) ) {
4093 $found_rules[] = $label;
4094 }
4095 }
4096
4097 $rules_summary = ! empty( $found_rules )
4098 ? implode( ', ', $found_rules )
4099 : __( 'Custom rules', 'vigilante' );
4100
4101 return array(
4102 'category' => 'extra',
4103 'item' => array(
4104 'file' => $relative,
4105 'type' => 'htaccess_in_uploads',
4106 /* translators: %s: Summary of rules found in the .htaccess file */
4107 'reason' => sprintf( __( '.htaccess in uploads (likely from plugin). Contains: %s', 'vigilante' ), $rules_summary ),
4108 ),
4109 );
4110 }
4111
4112 /**
4113 * Check content for suspicious patterns
4114 *
4115 * @param string $content File content.
4116 * @return bool
4117 */
4118 private function has_suspicious_content( $content ) {
4119 return (bool) $this->detect_suspicious_pattern( $content );
4120 }
4121
4122 /**
4123 * Detect specific suspicious pattern in file content
4124 *
4125 * Two detection levels:
4126 * - Standard (strict=false): for uploads where ANY PHP is already suspicious.
4127 * Single-function matches like dangerous functions, superglobals are enough.
4128 * - Strict (strict=true): for plugins/themes without checksums where PHP is expected.
4129 * Only flags clear obfuscation combos to avoid false positives on legitimate code.
4130 *
4131 * Patterns are loaded from an external JSON file (scan-patterns.json)
4132 * with base64-encoded needles to prevent WAF/antimalware false positives
4133 * on the scanner file itself.
4134 *
4135 * @param string $content File content.
4136 * @param bool $strict Use strict mode (fewer, higher-confidence patterns).
4137 * @return string|false The pattern found, or false.
4138 */
4139 private function detect_suspicious_pattern( $content, $strict = false ) {
4140
4141 if ( $strict ) {
4142 return $this->detect_strict_suspicious_pattern( $content );
4143 }
4144
4145 $patterns_data = $this->load_scan_patterns();
4146 if ( empty( $patterns_data['standard_patterns'] ) ) {
4147 return false;
4148 }
4149
4150 // Standard mode: broad detection for uploads and known-extra files
4151 foreach ( $patterns_data['standard_patterns'] as $encoded_needle => $label ) {
4152 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding pattern definitions, not user input.
4153 $needle = base64_decode( $encoded_needle );
4154 if ( $this->needle_present( $content, $needle ) ) {
4155 return $label;
4156 }
4157 }
4158
4159 // Check for preg_replace with /e modifier (code execution)
4160 if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
4161 return 'preg_replace /e modifier';
4162 }
4163
4164 // Check for long hex-encoded strings (obfuscated payloads)
4165 if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
4166 return 'hex-encoded string';
4167 }
4168
4169 // Check for heavily concatenated chr() calls (char-by-char obfuscation)
4170 if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
4171 return 'chr() concatenation obfuscation';
4172 }
4173
4174 return false;
4175 }
4176
4177 /**
4178 * Strict suspicious pattern detection for plugins/themes without checksums
4179 *
4180 * Only flags high-confidence obfuscation combos that are almost certainly malware.
4181 * Individual functions are normal in plugins and are not flagged.
4182 *
4183 * @param string $content File content.
4184 * @return string|false The pattern found, or false.
4185 */
4186 private function detect_strict_suspicious_pattern( $content ) {
4187
4188 $patterns_data = $this->load_scan_patterns();
4189 if ( empty( $patterns_data['strict_fragments'] ) ) {
4190 return false;
4191 }
4192
4193 // Decode fragment names from JSON
4194 $fragments = array();
4195 foreach ( $patterns_data['strict_fragments'] as $key => $encoded ) {
4196 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
4197 $fragments[ $key ] = base64_decode( $encoded );
4198 }
4199
4200 $ev = $fragments['ev'] ?? '';
4201 $b6 = $fragments['b6'] ?? '';
4202 $gz = $fragments['gz'] ?? '';
4203 $gu = $fragments['gu'] ?? '';
4204 $sr = $fragments['sr'] ?? '';
4205 $hb = $fragments['hb'] ?? '';
4206 $as = $fragments['as'] ?? '';
4207 $ss = $fragments['ss'] ?? '';
4208 $cf = $fragments['cf'] ?? '';
4209
4210 // Obfuscation combos: dangerous function wrapping decoded content
4211 $obfuscation_combos = array(
4212 '/' . $ev . '\s*\(\s*' . $b6 . '\s*\(/i' => $ev . '(' . $b6 . '())',
4213 '/' . $ev . '\s*\(\s*' . $gz . '\s*\(/i' => $ev . '(' . $gz . '())',
4214 '/' . $ev . '\s*\(\s*' . $gu . '\s*\(/i' => $ev . '(' . $gu . '())',
4215 '/' . $ev . '\s*\(\s*' . $sr . '\s*\(/i' => $ev . '(' . $sr . '())',
4216 '/' . $ev . '\s*\(\s*' . $hb . '\s*\(/i' => $ev . '(' . $hb . '())',
4217 '/' . $as . '\s*\(\s*' . $b6 . '\s*\(/i' => $as . '(' . $b6 . '())',
4218 '/' . $ev . '\s*\(\s*\$[a-z_]+\s*\(/i' => $ev . '($variable())',
4219 '/' . $ev . '\s*\(\s*' . $ss . '\s*\(/i' => $ev . '(' . $ss . '())',
4220 );
4221
4222 foreach ( $obfuscation_combos as $regex => $label ) {
4223 if ( preg_match( $regex, $content ) ) {
4224 return $label;
4225 }
4226 }
4227
4228 // Deprecated dynamic function constructor, nearly always malicious in modern code
4229 if ( ! empty( $cf ) && $this->needle_present( $content, $cf ) ) {
4230 return $cf . ')';
4231 }
4232
4233 // Remote fetch piped into unserialize: a PHP object-injection / supply-chain
4234 // vector seen in trojanized or nulled plugins (download a payload from a
4235 // remote URL and unserialize it). Requires a REAL unserialize() call
4236 // (not maybe_unserialize() / igbinary_unserialize(), which are common and
4237 // safe) sitting CLOSE TO a remote-fetch call. Earlier releases only
4238 // checked that both strings appeared somewhere in the file, which
4239 // false-positived on legitimate code using both in unrelated methods
4240 // (e.g. a theme reading a transient with maybe_unserialize() while
4241 // fetching its public IP with wp_remote_get()).
4242 $us = $fragments['us'] ?? '';
4243 if ( '' !== $us ) {
4244 $us_regex = '/(?<![a-z0-9_])' . preg_quote( $us, '/' ) . '\s*\(/i';
4245 $remote_fetchers = array(
4246 $fragments['wr'] ?? '', // wp_remote_get
4247 $fragments['rb'] ?? '', // wp_remote_retrieve_body
4248 $fragments['ce'] ?? '', // curl_exec
4249 );
4250 foreach ( $remote_fetchers as $rf ) {
4251 if ( '' === $rf ) {
4252 continue;
4253 }
4254 $rf_regex = '/(?<![a-z0-9_])' . preg_quote( $rf, '/' ) . '\s*\(/i';
4255 if ( $this->pattern_near( $content, $us_regex, $rf_regex, 600 ) ) {
4256 return $rf . '() + ' . $us . '() remote deserialization';
4257 }
4258 }
4259
4260 // file_get_contents() is treated separately from the fetchers
4261 // above: those are unambiguously remote, while file_get_contents
4262 // is PHP's most common LOCAL file reader, and reading a local
4263 // path right next to unserialize() is a legitimate pattern
4264 // (settings import/export, PSR-6 file caches shipped in premium
4265 // plugins, which have no wp.org checksums so this heuristic is
4266 // their only filter). It only acts as a remote fetcher when its
4267 // argument is a URL, so the combo additionally requires a
4268 // remote-scheme literal near the call before it fires.
4269 $fg = $fragments['fg'] ?? '';
4270 if ( '' !== $fg ) {
4271 $fg_regex = '/(?<![a-z0-9_])' . preg_quote( $fg, '/' ) . '\s*\(/i';
4272 $scheme_regex = '/(?:https?|ftps?):\/\/|php:\/\/input/i';
4273
4274 if ( $this->pattern_near( $content, $us_regex, $fg_regex, 600 )
4275 && $this->pattern_near( $content, $fg_regex, $scheme_regex, 600 ) ) {
4276 return $fg . '() + ' . $us . '() remote deserialization';
4277 }
4278 }
4279 }
4280
4281 // preg_replace with /e modifier (arbitrary code execution, deprecated)
4282 if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
4283 return 'preg_replace /e modifier';
4284 }
4285
4286 // Long hex-encoded strings (obfuscated payloads)
4287 if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
4288 return 'hex-encoded string';
4289 }
4290
4291 // Heavily concatenated chr() calls (char-by-char obfuscation)
4292 if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
4293 return 'chr() concatenation obfuscation';
4294 }
4295
4296 // Detect dangerous function names built from string concatenation
4297 if ( preg_match_all( '/\$([a-z_]\w*)\s*=\s*((?:["\'][a-z0-9_]*["\']\s*\.\s*)+["\'][a-z0-9_]*["\'])\s*;/i', $content, $matches, PREG_SET_ORDER ) ) {
4298 $dangerous_names = array();
4299 if ( ! empty( $patterns_data['dangerous_names'] ) ) {
4300 foreach ( $patterns_data['dangerous_names'] as $encoded_name ) {
4301 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
4302 $dangerous_names[] = base64_decode( $encoded_name );
4303 }
4304 }
4305
4306 foreach ( $matches as $match ) {
4307 $combined = strtolower( preg_replace( '/["\'\s\.]/', '', $match[2] ) );
4308 if ( in_array( $combined, $dangerous_names, true ) ) {
4309 $var_pattern = '/\$' . preg_quote( $match[1], '/' ) . '\s*\(/';
4310 if ( preg_match( $var_pattern, $content ) ) {
4311 return 'obfuscated ' . $combined . '() call';
4312 }
4313 }
4314 }
4315 }
4316
4317 return false;
4318 }
4319
4320 /**
4321 * Whether a scan needle is present as a real token rather than glued inside
4322 * a longer identifier.
4323 *
4324 * Function-call needles (an identifier followed by "(") are matched with a
4325 * left word boundary, so "file_get_contents(" no longer matches
4326 * "wpcom_vip_file_get_contents(", "unserialize(" no longer matches
4327 * "maybe_unserialize(", and "eval(" no longer matches "retrieval(". Needles
4328 * that are not plain identifiers (such as the "$_GET[" superglobal probes)
4329 * keep a plain case-insensitive substring search.
4330 *
4331 * @param string $content File content.
4332 * @param string $needle Decoded needle (e.g. "eval(", "$_GET[").
4333 * @return bool
4334 */
4335 private function needle_present( $content, $needle ) {
4336 if ( '' === $needle ) {
4337 return false;
4338 }
4339 if ( preg_match( '/^[a-z_][a-z0-9_]*\($/i', $needle ) ) {
4340 $fn = rtrim( $needle, '(' );
4341 return (bool) preg_match( '/(?<![a-z0-9_])' . preg_quote( $fn, '/' ) . '\s*\(/i', $content );
4342 }
4343 return stripos( $content, $needle ) !== false;
4344 }
4345
4346 /**
4347 * Whether two patterns both occur within $window bytes of each other.
4348 *
4349 * Used to require that correlated malware signals (for example a remote
4350 * fetch and an unserialize call) sit in the same code path instead of
4351 * merely coexisting somewhere in the file, which was a false-positive
4352 * source when only their presence was checked.
4353 *
4354 * @param string $content File content.
4355 * @param string $regex_a First anchored pattern (with delimiters and flags).
4356 * @param string $regex_b Second anchored pattern (with delimiters and flags).
4357 * @param int $window Maximum byte distance between a match of each.
4358 * @return bool
4359 */
4360 private function pattern_near( $content, $regex_a, $regex_b, $window ) {
4361 if ( ! preg_match_all( $regex_a, $content, $m_a, PREG_OFFSET_CAPTURE ) ) {
4362 return false;
4363 }
4364 if ( ! preg_match_all( $regex_b, $content, $m_b, PREG_OFFSET_CAPTURE ) ) {
4365 return false;
4366 }
4367 foreach ( $m_a[0] as $a ) {
4368 foreach ( $m_b[0] as $b ) {
4369 if ( abs( $a[1] - $b[1] ) <= $window ) {
4370 return true;
4371 }
4372 }
4373 }
4374 return false;
4375 }
4376
4377 /**
4378 * Whether a file is a text file worth normalizing before the fallback hash
4379 * comparison in hash_matches_published().
4380 *
4381 * @param string $file_path Absolute path.
4382 * @return bool
4383 */
4384 private function is_text_file( $file_path ) {
4385 $text_ext = array(
4386 'php', 'php3', 'php4', 'php5', 'php7', 'phtml',
4387 'js', 'css', 'html', 'htm', 'xml', 'svg',
4388 'txt', 'md', 'json', 'po', 'pot', 'yml', 'yaml', 'ini', 'csv',
4389 );
4390 return in_array( strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ), $text_ext, true );
4391 }
4392
4393 /**
4394 * Normalize a relative path for checksum-key comparison: forward slashes,
4395 * no doubled slashes, no leading "./" or "/". Case is preserved because
4396 * plugin and theme file systems are case-sensitive on most hosts.
4397 *
4398 * @param string $path Relative path.
4399 * @return string
4400 */
4401 private function normalize_rel_path( $path ) {
4402 $path = str_replace( '\\', '/', $path );
4403 $path = preg_replace( '#/+#', '/', $path );
4404 if ( 0 === strpos( $path, './' ) ) {
4405 $path = substr( $path, 2 );
4406 }
4407 return ltrim( $path, '/' );
4408 }
4409
4410 /**
4411 * Load scan patterns from external JSON file
4412 *
4413 * Patterns are stored in a JSON file with base64-encoded values
4414 * to prevent hosting WAF/antimalware from flagging the scanner
4415 * PHP file as suspicious.
4416 *
4417 * @return array Patterns data.
4418 */
4419 private function load_scan_patterns() {
4420 static $cached = null;
4421
4422 if ( null !== $cached ) {
4423 return $cached;
4424 }
4425
4426 $file = VIGILANTE_INCLUDES_DIR . 'scan-patterns.json';
4427
4428 if ( ! file_exists( $file ) ) {
4429 $cached = array();
4430 return $cached;
4431 }
4432
4433 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read, not remote.
4434 $json = file_get_contents( $file );
4435 $cached = json_decode( $json, true );
4436
4437 if ( ! is_array( $cached ) ) {
4438 $cached = array();
4439 }
4440
4441 return $cached;
4442 }
4443
4444 /**
4445 * Detect extra files in a directory that are not in checksums
4446 * (potential backdoors injected into plugins/themes)
4447 *
4448 * When $suspicious_only is true (no checksums available), only files
4449 * with suspicious code patterns are reported. This avoids flooding
4450 * results with every PHP file from plugins/themes not on WordPress.org.
4451 *
4452 * @param string $directory Directory to scan.
4453 * @param array $checksums Known checksums from WordPress.org (empty if unavailable).
4454 * @param string $type 'plugin' or 'theme'.
4455 * @param string $name Plugin or theme name.
4456 * @param bool $suspicious_only Only report files with suspicious patterns.
4457 * @return array Array with 'suspicious' and 'extra' sub-arrays.
4458 */
4459 private function detect_extra_files( $directory, $checksums, $type, $name, $suspicious_only = false ) {
4460 $found = array(
4461 'suspicious' => array(),
4462 'extra' => array(),
4463 );
4464 $max_extra = 50; // Limit to prevent timeout on large plugins
4465 $count = 0;
4466
4467 // Only check PHP files for performance
4468 $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar' );
4469
4470 // Pre-normalize the checksum keys once so path-shape differences
4471 // (Windows backslashes, a leading "./", doubled slashes) don't make a
4472 // known file look "extra" and get scanned or flagged. Case is preserved.
4473 $known_normalized = array();
4474 foreach ( array_keys( $checksums ) as $known_file ) {
4475 $known_normalized[ $this->normalize_rel_path( $known_file ) ] = true;
4476 }
4477
4478 try {
4479 $iterator = new RecursiveIteratorIterator(
4480 new RecursiveDirectoryIterator( $directory, RecursiveDirectoryIterator::SKIP_DOTS ),
4481 RecursiveIteratorIterator::LEAVES_ONLY
4482 );
4483
4484 foreach ( $iterator as $file ) {
4485 if ( $this->is_time_exceeded() || $count >= $max_extra ) {
4486 break;
4487 }
4488
4489 $file_path = $file->getPathname();
4490 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
4491
4492 // Only check PHP files
4493 if ( ! in_array( $extension, $php_extensions, true ) ) {
4494 continue;
4495 }
4496
4497 // Get relative path within plugin/theme directory, normalized so
4498 // path-shape quirks don't misclassify a known file as "extra".
4499 $norm_path = str_replace( '\\', '/', $file_path );
4500 $norm_dir = str_replace( '\\', '/', $directory );
4501 $relative_to_dir = $this->normalize_rel_path( str_replace( $norm_dir . '/', '', $norm_path ) );
4502
4503 // Skip if file is in the checksums (it's known)
4504 if ( isset( $known_normalized[ $relative_to_dir ] ) ) {
4505 continue;
4506 }
4507
4508 // Skip excluded paths
4509 if ( $this->is_path_excluded( $file_path ) ) {
4510 continue;
4511 }
4512
4513 // Skip "Silence is golden" placeholder index.php files used by
4514 // WordPress core and many plugins to prevent directory listings.
4515 // The check is content-based — an attacker cannot bypass it by
4516 // simply naming a payload file index.php.
4517 if ( $this->is_silence_golden_file( $file_path ) ) {
4518 continue;
4519 }
4520
4521 $count++;
4522 $relative = str_replace( ABSPATH, '', $file_path );
4523 $pattern = false;
4524
4525 // Check for suspicious content in extra files
4526 // Use strict mode for plugins without checksums to avoid false positives
4527 if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
4528 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4529 $pattern = $this->detect_suspicious_pattern( $content, $suspicious_only );
4530 }
4531
4532 $item = array(
4533 'file' => $relative,
4534 $type => $name,
4535 );
4536
4537 if ( $pattern ) {
4538 // Suspicious content: promote to suspicious category
4539 $item['type'] = 'suspicious_' . $type;
4540 /* translators: 1: Plugin or theme name, 2: Suspicious pattern found */
4541 $item['reason'] = sprintf( __( 'Injected file in %1$s with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
4542 $found['suspicious'][] = $item;
4543 } elseif ( ! $suspicious_only ) {
4544 // No suspicious patterns and checksums available: report as extra
4545 // Skipped in suspicious_only mode (no checksums) to avoid noise
4546 $item['type'] = 'extra_' . $type;
4547 $item['reason'] = __( 'PHP file not present in original distribution', 'vigilante' );
4548 $found['extra'][] = $item;
4549 }
4550 }
4551 } catch ( Exception $e ) {
4552 // Ignore iterator errors
4553 }
4554
4555 return $found;
4556 }
4557
4558 /**
4559 * Whether the given file is a trivial "Silence is golden" placeholder.
4560 *
4561 * WordPress core and most plugins drop an empty or near-empty index.php
4562 * inside their directories to block directory listings on misconfigured
4563 * servers. Those files trip the extra/suspicious detector even though
4564 * they're harmless. We whitelist them by content (not by name) so an
4565 * attacker cannot bypass the rule simply by calling a payload index.php.
4566 *
4567 * @param string $file_path Absolute path to the file being scanned.
4568 * @return bool True when the file is a known harmless placeholder.
4569 */
4570 private function is_silence_golden_file( $file_path ) {
4571 if ( 'index.php' !== basename( $file_path ) ) {
4572 return false;
4573 }
4574
4575 // Cap to avoid reading large files just to check this. Real placeholders
4576 // are always tiny (< 100 bytes); anything bigger isn't one.
4577 $size = @filesize( $file_path );
4578 if ( false === $size || $size > 256 ) {
4579 return false;
4580 }
4581
4582 $content = @file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4583 if ( false === $content ) {
4584 return false;
4585 }
4586
4587 $normalized = strtolower( trim( str_replace( array( "\r\n", "\r" ), "\n", $content ) ) );
4588
4589 $known = array(
4590 '',
4591 '<?php',
4592 '<?php //silence is golden.',
4593 '<?php // silence is golden.',
4594 '<?php //silence is golden',
4595 '<?php // silence is golden',
4596 );
4597
4598 return in_array( $normalized, $known, true );
4599 }
4600
4601 /**
4602 * Check if a path is excluded from scanning
4603 *
4604 * @param string $path File path.
4605 * @return bool
4606 */
4607 private function is_path_excluded( $path ) {
4608 $excluded = $this->options['excluded_paths'] ?? array();
4609
4610 if ( empty( $excluded ) ) {
4611 return false;
4612 }
4613
4614 $relative = $this->relative_path( $path );
4615
4616 foreach ( $excluded as $exclude ) {
4617 $exclude = trim( trim( str_replace( '\\', '/', (string) $exclude ) ), '/' );
4618
4619 if ( '' === $exclude ) {
4620 continue;
4621 }
4622
4623 /*
4624 * Two forms, both with real boundaries. Until 2.9.9 this was a plain
4625 * strpos() over the relative path, so an exclusion matched anywhere
4626 * inside it: "cache" also silenced a plugin folder named mycache,
4627 * "logs" silenced catalogs, and nothing told the user how much had
4628 * stopped being watched.
4629 *
4630 * Anything with a slash is a path: it excludes exactly that file, or
4631 * everything under it, anchored at the root of the installation.
4632 */
4633 if ( false !== strpos( $exclude, '/' ) ) {
4634 if ( $relative === $exclude || 0 === strpos( $relative, $exclude . '/' ) ) {
4635 return true;
4636 }
4637
4638 continue;
4639 }
4640
4641 /*
4642 * A bare name excludes any folder called exactly that, at any depth,
4643 * which is what someone typing "languages" means. It has to be the
4644 * whole segment, not a fragment of one.
4645 */
4646 if ( $relative === $exclude
4647 || 0 === strpos( $relative, $exclude . '/' )
4648 || false !== strpos( $relative, '/' . $exclude . '/' ) ) {
4649 return true;
4650 }
4651 }
4652
4653 return false;
4654 }
4655
4656 /**
4657 * Path relative to the WordPress directory, with forward slashes
4658 *
4659 * @since 2.9.9
4660 *
4661 * @param string $path Absolute path.
4662 * @return string
4663 */
4664 private function relative_path( $path ) {
4665 $path = str_replace( '\\', '/', (string) $path );
4666 $root = str_replace( '\\', '/', ABSPATH );
4667
4668 if ( 0 === strpos( $path, $root ) ) {
4669 $path = substr( $path, strlen( $root ) );
4670 }
4671
4672 return ltrim( $path, '/' );
4673 }
4674
4675 /**
4676 * Check if a file extension is excluded from scanning
4677 *
4678 * @param string $path File path.
4679 * @return bool
4680 */
4681 private function is_extension_excluded( $path ) {
4682 $excluded = $this->options['excluded_extensions'] ?? array();
4683
4684 if ( empty( $excluded ) ) {
4685 return false;
4686 }
4687
4688 $extension = '.' . strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
4689 $relative = strtolower( $this->relative_path( $path ) );
4690
4691 foreach ( $excluded as $exclude ) {
4692 $exclude = strtolower( trim( (string) $exclude ) );
4693
4694 if ( '' === $exclude ) {
4695 continue;
4696 }
4697
4698 /*
4699 * An extension on its own is global, as it has always been. Since
4700 * 2.9.9 it can also be scoped to a folder, written as
4701 * wp-content/languages/*.json, because the global form is a blunt
4702 * instrument: excluding .json to quiet the translation files also
4703 * stopped watching the 173 block.json files of core.
4704 */
4705 $scope = '';
4706
4707 if ( false !== strpos( $exclude, '*' ) ) {
4708 $parts = explode( '*', $exclude, 2 );
4709 $scope = trim( $parts[0], '/' );
4710 $exclude = $parts[1];
4711 }
4712
4713 if ( '' === $exclude ) {
4714 continue;
4715 }
4716
4717 // Support both ".log" and "log" formats
4718 if ( 0 !== strpos( $exclude, '.' ) ) {
4719 $exclude = '.' . $exclude;
4720 }
4721
4722 if ( $exclude !== $extension ) {
4723 continue;
4724 }
4725
4726 if ( '' === $scope ) {
4727 return true;
4728 }
4729
4730 if ( $relative === $scope || 0 === strpos( $relative, $scope . '/' ) ) {
4731 return true;
4732 }
4733 }
4734
4735 return false;
4736 }
4737
4738 /**
4739 * Filter out ignored files from results
4740 *
4741 * @param array $items Array of scan result items.
4742 * @return array Filtered items.
4743 */
4744 private function filter_ignored( $items ) {
4745 if ( empty( $this->ignored_files ) || empty( $items ) ) {
4746 return $items;
4747 }
4748
4749 return array_values(
4750 array_filter(
4751 $items,
4752 function ( $item ) {
4753 /*
4754 * On a network, wp-config.php and the root .htaccess are not a
4755 * site's to silence: a change to them is closed by approving it,
4756 * and approving takes a network administrator since 2.11.3. The
4757 * ignore list is an option of each site, so until 2.11.8 the
4758 * administrator of the main site without network rights hid a
4759 * pending change from the network administrator's own screen by
4760 * posting the file name to the ignore handler.
4761 */
4762 if ( is_multisite() && is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
4763 return true;
4764 }
4765
4766 $file = is_array( $item ) && isset( $item['file'] ) ? $item['file'] : '';
4767 return ! in_array( $file, $this->ignored_files, true );
4768 }
4769 )
4770 );
4771 }
4772
4773 /**
4774 * Send email notification based on notify_level setting
4775 *
4776 * Supports three levels:
4777 * - 'all': notify on any issues (modified + suspicious + extra)
4778 * - 'suspicious_only': notify only when suspicious or extra files found
4779 * - 'disabled': never send
4780 *
4781 * Backward compatible with old notify_on_changes boolean.
4782 *
4783 * @param array $results Scan results.
4784 */
4785 private function maybe_send_notification( $results ) {
4786 $options = is_array( $this->options ) ? $this->options : array();
4787
4788 // Count critical_config separately from regular modified so we can treat it
4789 // as "serious" for notification level purposes (same tier as suspicious/extra).
4790 $has_critical_config = false;
4791 foreach ( $results['modified'] ?? array() as $item ) {
4792 if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
4793 $has_critical_config = true;
4794 break;
4795 }
4796 }
4797
4798 // Collect closed/removed plugins (excluding ignored slugs). These count
4799 // as "serious" for notification purposes: a closed plugin in wp.org is
4800 // a security-critical finding, same tier as a suspicious file.
4801 $closed_plugins = $this->collect_closed_plugins_for_email();
4802 $has_closed = ! empty( $closed_plugins );
4803
4804 $has_suspicious = ! empty( $results['suspicious'] ) || ! empty( $results['extra'] ) || $has_critical_config || $has_closed;
4805 $has_modified = ! empty( $results['modified'] );
4806
4807 // Instant alert: send for suspicious, extra, critical_config, modified
4808 // files, or closed plugins.
4809 $instant_alert = ! empty( $options['instant_alert'] );
4810 if ( $instant_alert && ( $has_suspicious || $has_modified ) ) {
4811 $this->send_notification( $results, 'all', $closed_plugins );
4812 return;
4813 }
4814
4815 // Determine notify level with backward compatibility
4816 $notify_level = $options['notify_level'] ?? '';
4817
4818 // Backward compat: if notify_level not set, check old boolean
4819 if ( empty( $notify_level ) ) {
4820 if ( ! empty( $options['notify_on_changes'] ) ) {
4821 $notify_level = 'all';
4822 } else {
4823 $notify_level = 'disabled';
4824 }
4825 }
4826
4827 if ( 'disabled' === $notify_level ) {
4828 return;
4829 }
4830
4831 // 'suspicious_only' treats suspicious/extra, critical_config AND closed
4832 // plugins as serious.
4833 if ( 'suspicious_only' === $notify_level && ! $has_suspicious ) {
4834 return;
4835 }
4836
4837 if ( ! $has_suspicious && ! $has_modified ) {
4838 return;
4839 }
4840
4841 $this->send_notification( $results, $notify_level, $closed_plugins );
4842 }
4843
4844 /**
4845 * Collect the closed/removed plugins (excluding ignored slugs) so they can
4846 * be folded into the scan email digest. Returns an array keyed by slug.
4847 *
4848 * @return array
4849 */
4850 private function collect_closed_plugins_for_email() {
4851 if ( empty( $this->options['check_closed_plugins'] ) ) {
4852 return array();
4853 }
4854 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
4855 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
4856 }
4857 $checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
4858 return $checker->get_closed_plugins();
4859 }
4860
4861 /**
4862 * Merge scan results
4863 *
4864 * @param array $results1 First results.
4865 * @param array $results2 Second results.
4866 * @return array Merged results.
4867 */
4868 private function merge_results( $results1, $results2 ) {
4869 return array(
4870 'scanned' => $results1['scanned'] + ( $results2['scanned'] ?? 0 ),
4871 'ok' => $results1['ok'] + ( $results2['ok'] ?? 0 ),
4872 'modified' => array_merge( $results1['modified'], $results2['modified'] ?? array() ),
4873 'missing' => array_merge( $results1['missing'] ?? array(), $results2['missing'] ?? array() ),
4874 'suspicious' => array_merge( $results1['suspicious'] ?? array(), $results2['suspicious'] ?? array() ),
4875 'extra' => array_merge( $results1['extra'] ?? array(), $results2['extra'] ?? array() ),
4876 'new' => $results1['new'] ?? array(),
4877 'errors' => array_merge( $results1['errors'] ?? array(), $results2['errors'] ?? array() ),
4878 'scan_time' => $results1['scan_time'] ?? 0,
4879 'incomplete' => $results1['incomplete'] ?? false,
4880 );
4881 }
4882
4883 /**
4884 * Send notification email about scan results
4885 *
4886 * @param array $results Scan results.
4887 * @param string $notify_level Notification level ('all' or 'suspicious_only').
4888 * @param array $closed_plugins Optional map of slug=>state-entry for closed/removed
4889 * plugins to include as a dedicated section.
4890 */
4891 private function send_notification( $results, $notify_level = 'all', $closed_plugins = array() ) {
4892 $to = Vigilante_Email_Template::get_admin_recipients();
4893 $site_name = get_bloginfo( 'name' );
4894
4895 // Split critical_config files from regular modified so they get their own
4896 // prominent section in the email, next to suspicious/extra.
4897 $critical_config = array();
4898 $regular_modified = array();
4899 foreach ( $results['modified'] ?? array() as $item ) {
4900 if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
4901 // Synthesize a reason string with size + line-diff info so get_section_html shows it
4902 $baseline_size = $item['baseline_size'] ?? 0;
4903 $current_size = $item['current_size'] ?? 0;
4904 $added_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['added'] ?? array() ) : 0;
4905 $removed_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['removed'] ?? array() ) : 0;
4906 $diff_unavail = is_array( $item['diff'] ?? null ) && ! empty( $item['diff']['unavailable'] );
4907
4908 $reason = sprintf(
4909 /* translators: 1: baseline size, 2: current size */
4910 __( '%1$s → %2$s bytes', 'vigilante' ),
4911 number_format_i18n( $baseline_size ),
4912 number_format_i18n( $current_size )
4913 );
4914 if ( ! $diff_unavail ) {
4915 $reason .= sprintf( ' (+%d / -%d %s)', $added_count, $removed_count, __( 'lines', 'vigilante' ) );
4916 }
4917
4918 $item['reason'] = $reason;
4919 $critical_config[] = $item;
4920 } else {
4921 $regular_modified[] = $item;
4922 }
4923 }
4924
4925 $suspicious_count = count( $results['suspicious'] ?? array() );
4926 $extra_count = count( $results['extra'] ?? array() );
4927 $critical_config_count = count( $critical_config );
4928 $modified_count = count( $regular_modified );
4929 $closed_count = count( $closed_plugins );
4930
4931 // Use more urgent subject when suspicious files, critical config changes
4932 // or closed plugins are found (all three are security-critical).
4933 if ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 ) {
4934 $subject = sprintf(
4935 /* translators: %s: Site name */
4936 __( '[%s] SECURITY ALERT: File integrity issues detected', 'vigilante' ),
4937 $site_name
4938 );
4939 } else {
4940 $subject = sprintf(
4941 /* translators: %s: Site name */
4942 __( '[%s] File integrity issues detected', 'vigilante' ),
4943 $site_name
4944 );
4945 }
4946
4947 // Build HTML email using template wrapper
4948 $inner = '';
4949
4950 // Summary counts
4951 $inner .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom:20px;">';
4952 $inner .= '<tr>';
4953 if ( $suspicious_count > 0 ) {
4954 $inner .= $this->get_stat_cell( $suspicious_count, __( 'Suspicious', 'vigilante' ), '#d63638' );
4955 }
4956 if ( $extra_count > 0 ) {
4957 $inner .= $this->get_stat_cell( $extra_count, __( 'Extra', 'vigilante' ), '#b32d2e' );
4958 }
4959 if ( $critical_config_count > 0 ) {
4960 $inner .= $this->get_stat_cell( $critical_config_count, __( 'Critical', 'vigilante' ), '#e36210' );
4961 }
4962 if ( $closed_count > 0 ) {
4963 $inner .= $this->get_stat_cell( $closed_count, __( 'Closed', 'vigilante' ), '#d63638' );
4964 }
4965 if ( 'all' === $notify_level && $modified_count > 0 ) {
4966 $inner .= $this->get_stat_cell( $modified_count, __( 'Modified', 'vigilante' ), '#dba617' );
4967 }
4968 $inner .= $this->get_stat_cell( $results['scanned'] ?? 0, __( 'Scanned', 'vigilante' ), '#50575e' );
4969 $inner .= '</tr></table>';
4970
4971 // Suspicious files section
4972 if ( ! empty( $results['suspicious'] ) ) {
4973 $inner .= $this->get_section_html(
4974 __( 'Suspicious files', 'vigilante' ),
4975 __( 'These files may contain malicious code. Review immediately.', 'vigilante' ),
4976 $results['suspicious'],
4977 '#d63638',
4978 '#fef1f1',
4979 20,
4980 true
4981 );
4982 }
4983
4984 // Extra files section
4985 if ( ! empty( $results['extra'] ) ) {
4986 $inner .= $this->get_section_html(
4987 __( 'Extra files', 'vigilante' ),
4988 __( 'PHP files not in the original WordPress.org distribution.', 'vigilante' ),
4989 $results['extra'],
4990 '#b32d2e',
4991 '#fdf6f4',
4992 20,
4993 true
4994 );
4995 }
4996
4997 // Critical config files section (wp-config.php, .htaccess modified outside Vigilante)
4998 if ( ! empty( $critical_config ) ) {
4999 $inner .= $this->get_section_html(
5000 __( 'Critical config files modified', 'vigilante' ),
5001 __( 'These files are common targets for code injection. Review the changes and approve if they are legitimate.', 'vigilante' ),
5002 $critical_config,
5003 '#e36210',
5004 '#fdf2e6',
5005 10,
5006 true
5007 );
5008 }
5009
5010 // Modified files section (only if notify_level is 'all')
5011 if ( 'all' === $notify_level && ! empty( $regular_modified ) ) {
5012 $inner .= $this->get_section_html(
5013 __( 'Modified files', 'vigilante' ),
5014 __( 'Checksum mismatch with WordPress.org originals.', 'vigilante' ),
5015 $regular_modified,
5016 '#dba617',
5017 '#fdf8e8',
5018 15,
5019 false
5020 );
5021 }
5022
5023 // Closed + Removed plugins section.
5024 // Same tier as suspicious files: WordPress.org has flagged the plugin as
5025 // closed or removed, the site keeps running its code, and ignoring the
5026 // finding is an explicit per-slug action by the admin.
5027 if ( $closed_count > 0 ) {
5028 $inner .= $this->build_closed_plugins_email_section( $closed_plugins );
5029 }
5030
5031 // CTA button
5032 $inner .= Vigilante_Email_Template::button(
5033 admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-last-scan' ),
5034 __( 'Review in Vigilant', 'vigilante' )
5035 );
5036
5037 $is_alert = ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 );
5038 $title = $is_alert
5039 ? __( 'Security alert', 'vigilante' )
5040 : __( 'File integrity report', 'vigilante' );
5041
5042 Vigilante_Email_Template::send( $to, $subject, $title, $inner, $is_alert );
5043 }
5044
5045 /**
5046 * Build the closed + removed plugins block for the scan email.
5047 *
5048 * Reuses the same visual treatment as the suspicious files section
5049 * (red accent, danger description) because the security tier is the
5050 * same: WordPress.org has marked the plugin as compromised or removed.
5051 *
5052 * @param array $closed_plugins Map of slug=>state entry.
5053 * @return string HTML block.
5054 */
5055 private function build_closed_plugins_email_section( $closed_plugins ) {
5056 $color = '#d63638';
5057 $bg_color = '#fef1f1';
5058 $title = __( 'Closed + Removed plugins', 'vigilante' );
5059 $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' );
5060
5061 $html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">';
5062 $html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>';
5063 $html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $desc ) . '</p>';
5064
5065 $html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">';
5066 foreach ( $closed_plugins as $slug => $entry ) {
5067 $name = isset( $entry['name'] ) ? $entry['name'] : $slug;
5068 $version = isset( $entry['version'] ) ? $entry['version'] : '';
5069 $state = isset( $entry['state'] ) ? $entry['state'] : '';
5070 $state_label = 'closed' === $state ? __( 'Closed', 'vigilante' ) : __( 'Removed', 'vigilante' );
5071 $closed_date = isset( $entry['closed_date'] ) ? $entry['closed_date'] : '';
5072 $reason = isset( $entry['closed_reason_text'] ) && '' !== $entry['closed_reason_text']
5073 ? $entry['closed_reason_text']
5074 : '';
5075
5076 $detail_bits = array();
5077 $detail_bits[] = $state_label;
5078 if ( '' !== $closed_date ) {
5079 $detail_bits[] = esc_html( $closed_date );
5080 }
5081 if ( '' !== $version ) {
5082 $detail_bits[] = 'v' . esc_html( $version );
5083 }
5084
5085 $html .= '<tr>';
5086 $html .= '<td style="padding:4px 0;color:#1d2327;font-family:Consolas,Monaco,monospace;font-size:11px;word-break:break-all;">';
5087 $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>';
5088 $html .= '</td></tr>';
5089 $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>';
5090 if ( '' !== $reason ) {
5091 $html .= '<tr><td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td></tr>';
5092 }
5093 }
5094 $html .= '</table>';
5095 $html .= '</div>';
5096
5097 return $html;
5098 }
5099
5100 /**
5101 * Get a summary stat cell for email
5102 *
5103 * @param int $count Stat count.
5104 * @param string $label Stat label.
5105 * @param string $color Color hex.
5106 * @return string HTML table cell.
5107 */
5108 private function get_stat_cell( $count, $label, $color ) {
5109 $html = '<td style="text-align:center;padding:12px 8px;">';
5110 $html .= '<div style="font-size:24px;font-weight:700;color:' . $color . ';line-height:1.2;">' . (int) $count . '</div>';
5111 $html .= '<div style="font-size:11px;color:#50575e;text-transform:uppercase;letter-spacing:0.5px;">' . esc_html( $label ) . '</div>';
5112 $html .= '</td>';
5113
5114 return $html;
5115 }
5116
5117 /**
5118 * Get an HTML section for file list in email
5119 *
5120 * @param string $title Section title.
5121 * @param string $description Section description.
5122 * @param array $files Array of file items.
5123 * @param string $color Accent color.
5124 * @param string $bg_color Background color.
5125 * @param int $max Max files to show.
5126 * @param bool $show_reason Whether to show reason column.
5127 * @return string HTML.
5128 */
5129 private function get_section_html( $title, $description, $files, $color, $bg_color, $max, $show_reason ) {
5130 $total = count( $files );
5131 $shown = array_slice( $files, 0, $max );
5132
5133 $html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">';
5134 $html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>';
5135 $html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $description ) . '</p>';
5136
5137 $html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">';
5138 foreach ( $shown as $file ) {
5139 $file_path = is_array( $file ) ? ( $file['file'] ?? '' ) : (string) $file;
5140 $reason = is_array( $file ) ? ( $file['reason'] ?? '' ) : '';
5141
5142 $html .= '<tr>';
5143 $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>';
5144 $html .= '</tr>';
5145
5146 if ( $show_reason && ! empty( $reason ) ) {
5147 $html .= '<tr>';
5148 $html .= '<td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td>';
5149 $html .= '</tr>';
5150 }
5151 }
5152 $html .= '</table>';
5153
5154 if ( $total > $max ) {
5155 $html .= '<p style="margin:8px 0 0;font-size:12px;color:#787c82;">';
5156 /* translators: %d: Number of additional files */
5157 $html .= sprintf( esc_html__( '... and %d more', 'vigilante' ), $total - $max );
5158 $html .= '</p>';
5159 }
5160
5161 $html .= '</div>';
5162
5163 return $html;
5164 }
5165
5166 /**
5167 * Add a file to the ignored list
5168 *
5169 * @param string $file_path Relative file path to ignore.
5170 * @return bool
5171 */
5172 public function ignore_file( $file_path ) {
5173 $ignored = get_option( 'vigilante_ignored_files', array() );
5174
5175 if ( ! in_array( $file_path, $ignored, true ) ) {
5176 $ignored[] = sanitize_text_field( $file_path );
5177 return update_option( 'vigilante_ignored_files', $ignored );
5178 }
5179
5180 return true;
5181 }
5182
5183 /**
5184 * Remove a file from the ignored list
5185 *
5186 * @param string $file_path Relative file path to stop ignoring.
5187 * @return bool
5188 */
5189 public function unignore_file( $file_path ) {
5190 $ignored = get_option( 'vigilante_ignored_files', array() );
5191 $ignored = array_values( array_diff( $ignored, array( $file_path ) ) );
5192
5193 return update_option( 'vigilante_ignored_files', $ignored );
5194 }
5195
5196 /**
5197 * Get the list of ignored files
5198 *
5199 * @return array
5200 */
5201 public function get_ignored_files() {
5202 return get_option( 'vigilante_ignored_files', array() );
5203 }
5204
5205 /**
5206 * Clear all ignored files
5207 *
5208 * @return bool
5209 */
5210 public function clear_ignored_files() {
5211 return delete_option( 'vigilante_ignored_files' );
5212 }
5213
5214 /**
5215 * Get last scan results
5216 *
5217 * @return array|false
5218 */
5219 public function get_last_scan_results() {
5220 return get_option( 'vigilante_last_integrity_results', false );
5221 }
5222
5223 /**
5224 * Get last scan time
5225 *
5226 * @return int|false
5227 */
5228 public function get_last_scan_time() {
5229 return get_option( 'vigilante_last_integrity_scan', false );
5230 }
5231
5232 /**
5233 * Clear stored hashes
5234 *
5235 * @return bool
5236 */
5237 public function clear_hashes() {
5238 return $this->database->clear_file_hashes();
5239 }
5240 }