PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.4
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
vigilante / includes / class-file-integrity.php

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

8,141 lines 347.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File Integrity Class
4 *
5 * Handles file integrity monitoring and scanning
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_File_Integrity
17 *
18 * Manages file integrity checks against WordPress.org checksums
19 */
20 class Vigilante_File_Integrity {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * Database instance
31 *
32 * @var Vigilante_Database
33 */
34 private $database;
35
36 /**
37 * Activity log instance
38 *
39 * @var Vigilante_Activity_Log
40 */
41 private $activity_log;
42
43 /**
44 * File integrity options
45 *
46 * @var array
47 */
48 private $options;
49
50 /**
51 * WordPress version
52 *
53 * @var string
54 */
55 private $wp_version;
56
57 /**
58 * Ignored files list
59 *
60 * @var array
61 */
62 private $ignored_files;
63
64 /**
65 * Scan start time for timeout control
66 *
67 * @var float
68 */
69 private $scan_start_time = 0;
70
71 /**
72 * Seconds a scan runs for at most
73 *
74 * A scan is one request, and it starts from the beginning every time. On
75 * a site too large for this, what comes last is left unchecked on every
76 * scan, so the results say what was not reached (see not_checked_lines()).
77 */
78 const MAX_SCAN_SECONDS = 60;
79
80 /**
81 * Maximum scan time in seconds (default 60s for thorough scanning)
82 *
83 * @var int
84 */
85 private $max_scan_time = self::MAX_SCAN_SECONDS;
86
87 /**
88 * Largest file the search for hidden code reads, in bytes
89 *
90 * It used to be 512,000, and a larger file was not opened at all: in a
91 * plugin with no official copy that left it out of the results as if it
92 * were clean. There is still a cap because the file is read whole, but
93 * what is above it is reported as not searched instead of being passed
94 * over (see search_file()).
95 */
96 const MAX_SEARCHED_BYTES = 8388608;
97
98 /**
99 * Seconds a scan gives to the folders of wp-content that belong to nothing
100 *
101 * @var int
102 */
103 private $content_folders_time = 10;
104
105 /**
106 * Whether an expression of the search for hidden code gave up on the file
107 * being searched (see found())
108 *
109 * @var bool
110 */
111 private $search_failed = false;
112
113 /**
114 * Option name for critical files baseline hashes.
115 *
116 * @var string
117 */
118 const BASELINE_OPTION = 'vigilante_critical_files_baseline';
119
120 /**
121 * Option that records which version last redacted the stored baseline.
122 *
123 * @since 2.11.2
124 */
125 const BASELINE_REDACTION_OPTION = 'vigilante_baseline_redaction';
126
127 /**
128 * Network option recording that the one-off sweep of per-site baselines ran.
129 *
130 * @since 2.11.3
131 */
132 const BASELINE_SWEEP_OPTION = 'vigilante_baseline_sweep';
133
134 /**
135 * The migration the sweep marker stands for.
136 *
137 * A literal, not VIGILANTE_VERSION, and the difference is the whole point.
138 * 2.11.3 stored the running version, so every release after it rearmed the
139 * sweep: the first dashboard load on the main site walked the network with
140 * switch_to_blog() to find nothing, because there was nothing left to find,
141 * for ever, at the price of the one walk the marker exists to avoid.
142 *
143 * A plain boolean would fix that too, and would also leave no way to fire a
144 * second network sweep the day another migration needs one. A literal costs
145 * nothing today and keeps that door open. The redaction marker keeps the
146 * running version on purpose: there the point IS to run again when the list
147 * of what has to be redacted grows, and the cost of reopening it is one
148 * option read rather than a walk of the network.
149 *
150 * And it must be a value NO version ever wrote into this option. The first
151 * draft used '2.11.3', which is precisely what 2.11.3 wrote there, as its
152 * own VIGILANTE_VERSION and BEFORE starting the walk: out in the wild that
153 * value means "started, maybe unfinished". Reading it as "finished" left
154 * every network whose 2.11.3 walk was cut short unswept for good, subsites
155 * still holding the database password and the eight keys. Reproduced on the
156 * Multisite install by a third cross review. The price of the new value is
157 * that networks that did finish in 2.11.3 walk once more and find nothing.
158 *
159 * @since 2.11.4
160 */
161 const BASELINE_SWEEP_MIGRATION = 'network-sweep-done';
162
163 /**
164 * Network option with the fingerprint of every block Vigilant itself wrote
165 * into wp-config.php or the root .htaccess.
166 *
167 * The integrity scan leaves Vigilant's own blocks out of the hash, so that
168 * rewriting them is not reported as somebody else's change. Until 2.11.5 it
169 * left out whatever sat between the markers, without looking. From 2.11.5 a
170 * block is left out only if its content is exactly what Vigilant wrote, as
171 * recorded here at write time.
172 *
173 * @since 2.11.5
174 */
175 const OWNED_BLOCKS_OPTION = 'vigilante_owned_blocks';
176
177 /**
178 * Network option marking that the blocks already on disk have been claimed.
179 *
180 * @since 2.11.5
181 */
182 const OWNED_BLOCKS_CLAIM_OPTION = 'vigilante_owned_blocks_claim';
183
184 /**
185 * Value stored when the claim is done. Deliberately not a version number:
186 * that is the lesson of BASELINE_SWEEP_MIGRATION above.
187 *
188 * @since 2.11.5
189 */
190 const OWNED_BLOCKS_CLAIMED = 'claimed';
191
192 /**
193 * What replaces a secret value kept in the baseline.
194 *
195 * Fixed forever: if this string ever changes, every stored baseline
196 * suddenly differs from the freshly redacted file and every site reports a
197 * change to wp-config.php that never happened.
198 *
199 * @since 2.11.2
200 */
201 const REDACTED_MARKER = '[redacted by Vigilant]';
202
203 /**
204 * Constant names whose value is checked even where the file does not name them
205 *
206 * The eight WordPress keys and salts and the database credentials. Until
207 * 2.11.7 this list, plus names that read like a credential, was what got
208 * redacted, and a real wp-config.php collects secrets under any name:
209 * FTP_PASS, SMTP passwords, cloud keys inside serialize( array( ... ) ),
210 * any const. Since 2.11.8 every value is redacted and this list only feeds
211 * the output check of baseline_content().
212 *
213 * @since 2.11.2
214 *
215 * @var string[]
216 */
217 private static $secret_constants = array(
218 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST',
219 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
220 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
221 );
222
223 /**
224 * Core constants whose value stays readable in the baseline copy
225 *
226 * Where the site lives, where its folders are, how much memory it gets:
227 * none of it is a secret and all of it is what a diff of wp-config.php is
228 * read for. Every other value is redacted. A list of what is secret can
229 * never be complete, which is how 2.11.2 to 2.11.7 missed FTP_PASS; a list
230 * of what is not can be short and still be right.
231 *
232 * @since 2.11.8
233 *
234 * @var string[]
235 */
236 private static $readable_constants = array(
237 'ABSPATH', 'WPINC', 'WP_HOME', 'WP_SITEURL', 'WP_CONTENT_DIR', 'WP_CONTENT_URL',
238 'WP_PLUGIN_DIR', 'WP_PLUGIN_URL', 'WPMU_PLUGIN_DIR', 'WPMU_PLUGIN_URL', 'UPLOADS',
239 'WP_LANG_DIR', 'WP_TEMP_DIR', 'WP_DEBUG_LOG', 'WP_MEMORY_LIMIT', 'WP_MAX_MEMORY_LIMIT',
240 'WP_ENVIRONMENT_TYPE', 'WP_DEVELOPMENT_MODE', 'WP_AUTO_UPDATE_CORE', 'FS_METHOD',
241 'DB_CHARSET', 'DB_COLLATE', 'DOMAIN_CURRENT_SITE', 'PATH_CURRENT_SITE', 'NOBLOGREDIRECT',
242 'COOKIE_DOMAIN', 'COOKIEPATH', 'SITECOOKIEPATH', 'ADMIN_COOKIE_PATH', 'PLUGINS_COOKIE_PATH',
243 'WP_DEFAULT_THEME', 'WPLANG',
244 // Numeric core settings. Since 2.11.10 a number in the value of a
245 // define() is redacted like any other value, so the ones that are known
246 // not to be credentials are listed here to keep the diff useful.
247 'AUTOSAVE_INTERVAL', 'WP_POST_REVISIONS', 'EMPTY_TRASH_DAYS', 'WP_CRON_LOCK_TIMEOUT',
248 'FS_CHMOD_DIR', 'FS_CHMOD_FILE', 'SITE_ID_CURRENT_SITE', 'BLOG_ID_CURRENT_SITE',
249 );
250
251 /**
252 * Read the critical files baseline, from where it belongs
253 *
254 * Both watched files, wp-config.php and the root .htaccess, belong to the
255 * whole network: there is one of each per installation, not one per site.
256 * Keeping the baseline in a per-site option meant every site of a network
257 * stored its own copy of the same wp-config.php, so a network of fifty
258 * sites held fifty copies of the same credentials, and a cleanup that ran
259 * on one site left the other forty nine untouched. Reported by @calzbert on
260 * 10 sep 2026 and reproduced on the Multisite install. Since 2.11.3 there
261 * is one baseline per network.
262 *
263 * No is_multisite() branch on purpose, and this is worth reading before
264 * anyone adds one back: the core functions already make the distinction.
265 * Verified in the installed core, wp-includes/option.php, where
266 * get_network_option() falls back to get_option() on a single site and
267 * update_network_option() falls back to update_option( $option, $value,
268 * false ), autoload already off, which is exactly what this needs. Two
269 * branches doing the same thing are two branches that can drift apart,
270 * and one of them did during this very change.
271 *
272 * @since 2.11.3
273 *
274 * @return array
275 */
276 private function read_baseline() {
277 $baseline = get_site_option( self::BASELINE_OPTION, array() );
278
279 return is_array( $baseline ) ? $baseline : array();
280 }
281
282 /**
283 * Store the critical files baseline where read_baseline() looks for it
284 *
285 * @since 2.11.3
286 *
287 * @param array $baseline Baseline to store.
288 * @return bool
289 */
290 private function write_baseline( $baseline ) {
291 return update_site_option( self::BASELINE_OPTION, $baseline );
292 }
293
294 /**
295 * Fingerprint of a block exactly as the integrity scan reads it back
296 *
297 * @since 2.11.5
298 *
299 * @param string $block Block from start marker to end marker, inclusive.
300 * @return string
301 */
302 private static function block_fingerprint( $block ) {
303 return md5( str_replace( array( "\r\n", "\r" ), "\n", (string) $block ) );
304 }
305
306 /**
307 * Record a block Vigilant has just written
308 *
309 * Called by the writers right after a verified write, so the scan can tell
310 * Vigilant's block from anything else carrying the same markers. By default
311 * it replaces the earlier record for that marker: after a write, only the
312 * block just written is Vigilant's.
313 *
314 * @since 2.11.5
315 *
316 * @param string $filename 'wp-config.php' or '.htaccess'.
317 * @param string $marker_start Start marker of the block.
318 * @param string $block Block from start marker to end marker, inclusive.
319 * @param bool $replace Drop earlier records for the same marker first.
320 * @return bool
321 */
322 public static function remember_owned_block( $filename, $marker_start, $block, $replace = true ) {
323 $owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() );
324 $owned = is_array( $owned ) ? $owned : array();
325 $file = ( isset( $owned[ $filename ] ) && is_array( $owned[ $filename ] ) ) ? $owned[ $filename ] : array();
326
327 if ( $replace ) {
328 foreach ( $file as $fingerprint => $marker ) {
329 if ( $marker === $marker_start ) {
330 unset( $file[ $fingerprint ] );
331 }
332 }
333 }
334
335 $file[ self::block_fingerprint( $block ) ] = $marker_start;
336 $owned[ $filename ] = $file;
337
338 return update_site_option( self::OWNED_BLOCKS_OPTION, $owned );
339 }
340
341 /**
342 * Forget the blocks recorded for a marker, once Vigilant has removed them
343 *
344 * @since 2.11.5
345 *
346 * @param string $filename 'wp-config.php' or '.htaccess'.
347 * @param string $marker_start Start marker of the block.
348 * @return bool
349 */
350 public static function forget_owned_blocks( $filename, $marker_start ) {
351 $owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() );
352
353 if ( ! is_array( $owned ) || empty( $owned[ $filename ] ) || ! is_array( $owned[ $filename ] ) ) {
354 return true;
355 }
356
357 $changed = false;
358
359 foreach ( $owned[ $filename ] as $fingerprint => $marker ) {
360 if ( $marker === $marker_start ) {
361 unset( $owned[ $filename ][ $fingerprint ] );
362 $changed = true;
363 }
364 }
365
366 return $changed ? update_site_option( self::OWNED_BLOCKS_OPTION, $owned ) : true;
367 }
368
369 /**
370 * Whether a block is one Vigilant wrote
371 *
372 * @since 2.11.5
373 *
374 * @param string $filename 'wp-config.php' or '.htaccess'.
375 * @param string $block Block from start marker to end marker, inclusive.
376 * @return bool
377 */
378 private static function is_owned_block( $filename, $block ) {
379 $owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() );
380
381 return is_array( $owned )
382 && isset( $owned[ $filename ] )
383 && is_array( $owned[ $filename ] )
384 && isset( $owned[ $filename ][ self::block_fingerprint( $block ) ] );
385 }
386
387 /**
388 * Whether the blocks already on disk have been claimed
389 *
390 * @since 2.11.5
391 *
392 * @return bool
393 */
394 private function owned_blocks_claimed() {
395 return self::OWNED_BLOCKS_CLAIMED === get_site_option( self::OWNED_BLOCKS_CLAIM_OPTION );
396 }
397
398 /**
399 * The baseline copy of a critical file, with no secret in it
400 *
401 * The integrity scan keeps a copy of wp-config.php so it can show which
402 * lines changed. Until 2.11.1 that copy was the file itself minus the
403 * plugin's own blocks, so the options table held the database password and
404 * the eight authentication keys and salts, and anybody who later read the
405 * database or a backup of it got them without ever touching the
406 * filesystem. Reported by the automated security review of wp.org on 9 sep
407 * 2026 and fixed in 2.11.2.
408 *
409 * The hash is still taken over the whole file, so a change to a secret is
410 * still detected; what changes is that the diff cannot show it, which is
411 * the right trade.
412 *
413 * @since 2.11.2
414 *
415 * @param string $filename Critical file name.
416 * @param string $normalized Normalized content.
417 * @return string Content safe to store, or '' when it cannot be made safe.
418 */
419 private function baseline_content( $filename, $normalized ) {
420 if ( '.htaccess' === $filename ) {
421 return $this->redact_server_secrets( $normalized );
422 }
423
424 if ( 'wp-config.php' !== $filename ) {
425 return $normalized;
426 }
427
428 // Calculados una sola vez: los usa la redaccion (para no conservar un
429 // numero que ademas este en vigor) y el control de salida de abajo.
430 $live_values = $this->values_in_force( $normalized );
431 $redacted = $this->redact_secrets( $normalized, $live_values );
432
433 /*
434 * Belt and braces, and this is the part that matters: the redaction
435 * above is the thing most likely to miss a shape nobody thought of,
436 * and the cost of missing one is a secret in the database. So the
437 * result is checked against the values actually in force, and if any
438 * of them survived, nothing is stored at all. The scan then reports the
439 * change without a line diff, which the interface already handles,
440 * instead of leaking.
441 *
442 * Until 2.11.7 the check covered the twelve constants of WordPress and
443 * nothing else, so a value the regular expression missed went straight
444 * through it. It now covers every constant the file names and every
445 * environment variable it reads.
446 *
447 * It runs against the copy that is actually stored. A value in force
448 * that sits inside the value of a readable constant, such as a Redis
449 * prefix equal to the domain inside WP_HOME, is not a secret left
450 * behind, so that one alone is not looked for; without that, every such
451 * site would lose its diff. The first version of this checked a
452 * stricter copy instead, and a secret inside a kept include path went
453 * straight past it (cross review of 2.11.8).
454 *
455 * Only values of eight characters or more are checked: DB_NAME is
456 * often something like "local" or "wp", and looking for that inside a
457 * PHP file matches by accident every time.
458 */
459 if ( '' === $redacted ) {
460 return '';
461 }
462
463 $shown = $this->readable_values_in_force();
464
465 foreach ( $live_values as $value ) {
466 foreach ( $shown as $readable ) {
467 if ( false !== strpos( $readable, $value ) ) {
468 continue 2;
469 }
470 }
471
472 if ( false !== strpos( $redacted, $value ) ) {
473 return '';
474 }
475 }
476
477 return $redacted;
478 }
479
480 /**
481 * Replace every value in wp-config.php with a marker
482 *
483 * Reads the file as PHP tokens and replaces every string in it: quoted,
484 * with variables inside, heredoc and nowdoc. What stays is what names a
485 * thing rather than holding it: the name passed to define(), defined(),
486 * constant() and getenv(), the name in putenv( 'NAME=value' ), array keys,
487 * an index such as $_ENV['NAME'], and strings of a single character that
488 * are not the value of a define(). Also the value of the constants in
489 * $readable_constants, the table prefix and a path passed to require or
490 * include, which are not secrets and are what a diff of this file is read
491 * for. A path is kept only while it looks like one, and only up to where
492 * its expression ends.
493 *
494 * Until 2.11.7 this was a regular expression over define() with a list of
495 * names, and it missed FTP_PASS, SMTP passwords, cloud keys inside
496 * serialize( array( ... ) ), every const and every value read with a
497 * fallback. Measured while preparing 2.11.8: 10 of 15 real shapes stored
498 * their secret.
499 *
500 * The marker always goes in single quotes, whatever the original used, so
501 * a copy redacted by an earlier version and the same file redacted today
502 * read the same line for line.
503 *
504 * @since 2.11.2
505 * @since 2.11.8 Reads tokens and redacts every value.
506 *
507 * @param string $content Normalized wp-config.php content.
508 * @return string Redacted content, or '' when it cannot be read as tokens.
509 */
510 private function redact_secrets( $content, $live_values = array() ) {
511 if ( ! function_exists( 'token_get_all' ) ) {
512 return '';
513 }
514
515 $marker = "'" . self::REDACTED_MARKER . "'";
516 $tokens = self::merged_tokens( token_get_all( (string) $content ) );
517 $count = count( $tokens );
518 $names = defined( 'T_NAME_FULLY_QUALIFIED' ) ? array( T_STRING, T_NAME_FULLY_QUALIFIED ) : array( T_STRING );
519 $includes = array( T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE );
520 $out = '';
521 $depth = 0;
522 $keep_until = -1;
523 $define_at = -1;
524 $in_include = false;
525 $include_depth = 0;
526 $include_ends = array( T_CLOSE_TAG, T_BOOLEAN_OR, T_BOOLEAN_AND, T_LOGICAL_OR, T_LOGICAL_AND, T_COALESCE );
527
528 for ( $i = 0; $i < $count; $i++ ) {
529 list( $type, $text, $plain ) = $tokens[ $i ];
530
531 if ( '(' === $type ) {
532 $depth++;
533 } elseif ( ')' === $type ) {
534 // The closing parenthesis of a readable define(), or of any define().
535 if ( $depth === $keep_until ) {
536 $keep_until = -1;
537 }
538 if ( $depth === $define_at ) {
539 $define_at = -1;
540 }
541 $depth--;
542
543 // A parenthesis that closes around the include ends its path.
544 if ( $in_include && $depth < $include_depth ) {
545 $in_include = false;
546 }
547 } elseif ( in_array( $type, $includes, true ) ) {
548 $in_include = true;
549 $include_depth = $depth;
550 } elseif ( $in_include && ( in_array( $type, array( ';', '{', '}', '?', ':', ',' ), true ) || in_array( $type, $include_ends, true ) ) ) {
551 /*
552 * The path of an include ends where its expression does. The
553 * first version of this only ended it at ';', so the value in
554 * `( include 'db.php' ) || define( 'FTP_PASS', '...' )`, in a
555 * ternary after require, or after a closing tag, was kept.
556 * Found by the cross review of 2.11.8.
557 */
558 $in_include = false;
559 }
560
561 if ( T_COMMENT === $type || T_DOC_COMMENT === $type ) {
562 $out .= $this->redact_comment( $text );
563 continue;
564 }
565
566 if ( T_INLINE_HTML === $type ) {
567 $out .= ( '' === trim( $text ) ) ? $text : $marker;
568 continue;
569 }
570
571 /*
572 * A value that is not a quoted string is still a value. Until
573 * 2.11.10 only string tokens were looked at, so
574 * define( 'SERVICE_TOKEN', 12345678 ) put the live token in the copy
575 * kept in the database. Reported by the wp.org automated review of
576 * 2.11.9.
577 *
578 * The first fix here redacted a number only in the value position of
579 * a define(), which is the shape that was reported and not the shape
580 * of the problem. The second cross review of 2.11.10 measured nine
581 * more: a negative number, one in parentheses, one inside
582 * array( ... ), one in a ternary, a const, and four that are not
583 * constants at all and so the output check cannot catch either, the
584 * worst of them the documented way of configuring Redis,
585 * $redis_server = array( 'auth' => 12345678 ). So a number is now
586 * treated like a string: redacted unless the place it sits in is one
587 * of the few that cannot hold a credential, which is how the rest of
588 * this function has been written since 2.11.8 (a list of what may be
589 * shown, never a list of what is secret).
590 *
591 * Losing a number from the diff costs little and buys the same trade
592 * as everywhere else: the hash still covers the whole file, so a
593 * change is detected even where the diff can no longer show it. The
594 * numeric core settings are in $readable_constants so the diff of a
595 * normal wp-config.php keeps saying what it used to.
596 */
597 if ( T_LNUMBER === $type || T_DNUMBER === $type ) {
598 $nprev = self::significant_token( $tokens, $i, -1 );
599 $nnext = self::significant_token( $tokens, $i, 1 );
600 $nptype = ( null === $nprev ) ? null : $tokens[ $nprev ][0];
601 $nntype = ( null === $nnext ) ? null : $tokens[ $nnext ][0];
602 $nbefore = ( '[' === $nptype ) ? self::significant_token( $tokens, $nprev, -1 ) : null;
603 $nbtoken = ( null === $nbefore ) ? array( null, null ) : $tokens[ $nbefore ];
604 $nvalue = ( -1 !== $define_at && $depth === $define_at && ',' === $nptype );
605
606 $nkeep = $keep_until >= 0
607 || T_DOUBLE_ARROW === $nntype
608 || ( '[' === $nptype && ']' === $nntype && in_array( $nbtoken[0], array( T_VARIABLE, T_STRING, ']', ')', '}' ), true ) )
609 || ( strlen( $text ) <= 1 && ! $nvalue );
610
611 /*
612 * Except when that same number is a value actually in force. The
613 * positions kept above are kept because a credential does not live
614 * in them, which is true, but it says nothing about the number
615 * itself: with
616 * define( 'SERVICE_TOKEN', 12345678 );
617 * $a = $config[12345678];
618 * the value was redacted in the define and kept in the index, so it
619 * survived, and the output check below did what it is there for and
620 * threw the whole copy away. No leak, but the diff of that
621 * wp-config.php was lost for good, which is the regression 2.11.8
622 * fixed, coming back through the numbers added in 2.11.10. Found by
623 * the third cross review.
624 *
625 * Strings are deliberately NOT treated this way: there, a value in
626 * force sitting in a kept position (an include path, an array key)
627 * can BE the secret, and losing the diff is the right answer. It is
628 * what poc/wpconfig-baseline-secretos.sh checks and it stays.
629 */
630 if ( $nkeep && in_array( $text, $live_values, true ) ) {
631 $nkeep = false;
632 }
633
634 $out .= $nkeep ? $text : $marker;
635 continue;
636 }
637
638 if ( 'string' !== $type ) {
639 $out .= $text;
640 continue;
641 }
642
643 $prev = self::significant_token( $tokens, $i, -1 );
644 $next = self::significant_token( $tokens, $i, 1 );
645 $ptype = ( null === $prev ) ? null : $tokens[ $prev ][0];
646 $ntype = ( null === $next ) ? null : $tokens[ $next ][0];
647 $call = ( '(' === $ptype ) ? self::significant_token( $tokens, $prev, -1 ) : null;
648 $inner = $plain ? substr( $text, 1, -1 ) : null;
649
650 if ( $plain && null !== $call && in_array( $tokens[ $call ][0], $names, true ) ) {
651 $function = strtolower( ltrim( $tokens[ $call ][1], '\\' ) );
652
653 if ( in_array( $function, array( 'define', 'defined', 'constant', 'getenv' ), true ) ) {
654 if ( 'define' === $function ) {
655 $define_at = $depth;
656
657 if ( in_array( $inner, self::$readable_constants, true ) ) {
658 $keep_until = $depth;
659 }
660 }
661 $out .= $text;
662 continue;
663 }
664
665 if ( 'putenv' === $function && false !== strpos( $inner, '=' ) ) {
666 $out .= "'" . substr( $inner, 0, strpos( $inner, '=' ) + 1 ) . self::REDACTED_MARKER . "'";
667 continue;
668 }
669 }
670
671 // The token before an opening bracket or an assignment, when there is one.
672 $before = ( '[' === $ptype || '=' === $ptype ) ? self::significant_token( $tokens, $prev, -1 ) : null;
673 $btoken = ( null === $before ) ? array( null, null ) : $tokens[ $before ];
674
675 /*
676 * The value of a define() is redacted whatever its length, as it was
677 * up to 2.11.7, so an empty password reads the same in a copy stored
678 * then as in today's; the first version of this kept strings of one
679 * character there and a file awaiting review showed credential lines
680 * nobody had touched (cross review of 2.11.8).
681 */
682 $is_define_value = ( -1 !== $define_at && $depth === $define_at && ',' === $ptype );
683 $is_path = $in_include && $plain
684 && preg_match( '#^[A-Za-z0-9_./\-]+$#', (string) $inner )
685 && ( false !== strpos( (string) $inner, '/' ) || '.php' === substr( (string) $inner, -4 ) );
686
687 $keep = ( $plain && strlen( $inner ) <= 1 && ! $is_define_value )
688 || T_DOUBLE_ARROW === $ntype
689 || ( '[' === $ptype && ']' === $ntype && in_array( $btoken[0], array( T_VARIABLE, T_STRING, ']', ')', '}' ), true ) )
690 || $keep_until >= 0
691 || $is_path
692 || ( '=' === $ptype && ';' === $ntype && T_VARIABLE === $btoken[0] && '$table_prefix' === $btoken[1] );
693
694 $out .= $keep ? $text : $marker;
695 }
696
697 return $out;
698 }
699
700 /**
701 * PHP tokens with every string folded into a single token
702 *
703 * The tokenizer splits a string with variables inside, a heredoc and a
704 * backtick command into several tokens. For the redaction each of them is
705 * one value, so they come back as a single token of type 'string'. The
706 * third field says whether it is a plain quoted literal.
707 *
708 * @since 2.11.8
709 *
710 * @param array $raw Output of token_get_all().
711 * @return array List of array( type, text, plain ).
712 */
713 private static function merged_tokens( $raw ) {
714 $tokens = array();
715 $count = count( $raw );
716
717 for ( $i = 0; $i < $count; $i++ ) {
718 $token = $raw[ $i ];
719
720 if ( '"' === $token || '`' === $token ) {
721 $text = $token;
722 for ( $i++; $i < $count; $i++ ) {
723 $text .= is_array( $raw[ $i ] ) ? $raw[ $i ][1] : $raw[ $i ];
724 if ( $raw[ $i ] === $token ) {
725 break;
726 }
727 }
728 $tokens[] = array( 'string', $text, false );
729 continue;
730 }
731
732 if ( is_array( $token ) && T_START_HEREDOC === $token[0] ) {
733 $text = $token[1];
734 for ( $i++; $i < $count; $i++ ) {
735 $text .= is_array( $raw[ $i ] ) ? $raw[ $i ][1] : $raw[ $i ];
736 if ( is_array( $raw[ $i ] ) && T_END_HEREDOC === $raw[ $i ][0] ) {
737 break;
738 }
739 }
740 $tokens[] = array( 'string', $text, false );
741 continue;
742 }
743
744 // An unterminated string comes back as T_ENCAPSED_AND_WHITESPACE
745 // on its own, and it is a value like any other.
746 if ( is_array( $token ) && ( T_CONSTANT_ENCAPSED_STRING === $token[0] || T_ENCAPSED_AND_WHITESPACE === $token[0] ) ) {
747 $tokens[] = array( 'string', $token[1], T_CONSTANT_ENCAPSED_STRING === $token[0] );
748 continue;
749 }
750
751 $tokens[] = is_array( $token ) ? array( $token[0], $token[1], false ) : array( $token, $token, false );
752 }
753
754 return $tokens;
755 }
756
757 /**
758 * Index of the nearest token that is not whitespace or a comment
759 *
760 * @since 2.11.8
761 *
762 * @param array $tokens Output of merged_tokens().
763 * @param int $from Index to start from, not included.
764 * @param int $step -1 to look back, 1 to look ahead.
765 * @return int|null
766 */
767 private static function significant_token( $tokens, $from, $step ) {
768 $count = count( $tokens );
769
770 for ( $i = $from + $step; $i >= 0 && $i < $count; $i += $step ) {
771 if ( ! in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
772 return $i;
773 }
774 }
775
776 return null;
777 }
778
779 /**
780 * Redact a comment, keeping its plain words
781 *
782 * To the tokenizer a comment is text, and wp-config.php files keep old
783 * credentials in them, commented out or in a note. The first version of
784 * this, in the same release, redacted what was between quotes: an
785 * apostrophe in prose ("Don't use 'the-old-password'") paired with the
786 * opening quote of the secret and left it out, and a secret without quotes
787 * was never touched. Found by the cross review of 2.11.8.
788 *
789 * So it works the other way round. A comment keeps its plain words
790 * (lowercase, capitalised or uppercase letters, or two capitalised parts
791 * such as WordPress, and docblock tags), constant names, and anything
792 * shorter than eight characters; every other run of characters, a URL, a
793 * key, a password with a digit in it, becomes the marker. The value of a
794 * commented-out define() goes in single quotes whatever its length, as in
795 * code and as 2.11.2 to 2.11.7 wrote it, so a copy stored by those
796 * versions reads the same line for line. What this cannot tell from prose
797 * is a password made only of plain letters; the output check still
798 * catches it when it is a value in force.
799 *
800 * @since 2.11.8
801 *
802 * @param string $comment Comment token text.
803 * @return string
804 */
805 private function redact_comment( $comment ) {
806 $marker = self::REDACTED_MARKER;
807 $readable = self::$readable_constants;
808
809 // Only the text between the delimiters is redacted: "/**#@-*/" in
810 // wp-config-sample.php is a single run of eight characters, and
811 // replacing it whole took the comment markers with it.
812 if ( ! preg_match( '#\A(/\*\*?|//|\#)(.*?)(\*/)?\z#s', $comment, $parts ) ) {
813 $parts = array( $comment, '', $comment );
814 }
815
816 $open = $parts[1];
817 $close = isset( $parts[3] ) ? $parts[3] : '';
818 $comment = preg_replace_callback(
819 '/(\bdefine\s*\(\s*([\'"])((?:\\\\.|(?!\2).)*)\2\s*,\s*)([\'"])((?:\\\\.|(?!\4).)*)\4/i',
820 function ( $match ) use ( $marker, $readable ) {
821 return in_array( $match[3], $readable, true ) ? $match[0] : $match[1] . "'" . $marker . "'";
822 },
823 $parts[2]
824 );
825
826 if ( null === $comment ) {
827 return '';
828 }
829
830 $redacted = preg_replace_callback(
831 '/[^\s\'"`(),;\[\]{}<>=]+/u',
832 function ( $match ) use ( $marker ) {
833 $word = $match[0];
834 $core = rtrim( $word, '.:!?' );
835
836 // Plain words only, without hyphens: a passphrase written as
837 // lowercase words joined by hyphens reads as prose otherwise, and
838 // the PoC of this very fix caught one surviving.
839 if ( strlen( $core ) < 8
840 || preg_match( '/^@?(?:\p{Lu}?\p{Ll}+|\p{Lu}+)$/u', $core )
841 || preg_match( '/^\p{Lu}\p{Ll}+\p{Lu}\p{Ll}+$/u', $core )
842 || preg_match( '/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/', $core )
843 ) {
844 return $word;
845 }
846
847 return $marker . substr( $word, strlen( $core ) );
848 },
849 $comment
850 );
851
852 // A failed replacement, on invalid UTF-8 for one, drops the comment
853 // rather than keep it whole.
854 return ( null === $redacted ) ? '' : $open . $redacted . $close;
855 }
856
857 /**
858 * Values in force of what a wp-config.php names
859 *
860 * Every user constant whose name appears in the file, the twelve of
861 * WordPress wherever they were defined, and the environment variables the
862 * file reads or sets. Arrays are walked to their leaves, since define()
863 * takes arrays. Strings and numbers both: the first version of this counted
864 * numbers and wiped the diff of any file with a large number in force (cross
865 * review of 2.11.8), so they were dropped, and 2.11.10 had to bring them
866 * back because a credential written as a number, which the wp.org review of
867 * 2.11.9 reported, is exactly what this check has to be able to see. The
868 * eight character floor is what keeps the old problem away. The readable
869 * constants are left out, and so is anything shorter than that.
870 *
871 * @since 2.11.8
872 *
873 * @param string $content Normalized wp-config.php content.
874 * @return string[]
875 */
876 private function values_in_force( $content ) {
877 $defined = get_defined_constants( true );
878 $user = isset( $defined['user'] ) ? $defined['user'] : array();
879 $names = self::$secret_constants;
880 $values = array();
881
882 if ( preg_match_all( '/[A-Za-z_][A-Za-z0-9_]*/', (string) $content, $words ) ) {
883 $names = array_merge( $names, $words[0] );
884 }
885
886 foreach ( array_unique( $names ) as $name ) {
887 if ( array_key_exists( $name, $user ) && ! in_array( $name, self::$readable_constants, true ) ) {
888 $values = array_merge( $values, self::string_leaves( $user[ $name ] ) );
889 }
890 }
891
892 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 ) ) {
893 foreach ( array_filter( array_merge( $env[1], $env[2] ) ) as $name ) {
894 $value = getenv( $name );
895
896 if ( is_string( $value ) ) {
897 $values[] = $value;
898 }
899 }
900 }
901
902 $long = array();
903
904 foreach ( $values as $value ) {
905 if ( strlen( $value ) >= 8 ) {
906 $long[ $value ] = $value;
907 }
908 }
909
910 return array_values( $long );
911 }
912
913 /**
914 * Every string inside a constant value
915 *
916 * @since 2.11.8
917 *
918 * @param mixed $value Constant value.
919 * @return string[]
920 */
921 private static function string_leaves( $value ) {
922 if ( is_array( $value ) ) {
923 $leaves = array();
924
925 foreach ( $value as $item ) {
926 $leaves = array_merge( $leaves, self::string_leaves( $item ) );
927 }
928
929 return $leaves;
930 }
931
932 if ( is_string( $value ) ) {
933 return array( $value );
934 }
935
936 /*
937 * A number is a value too. Until 2.11.10 this returned nothing for one,
938 * so the output check had no way to see a credential written as
939 * define( 'SERVICE_TOKEN', 12345678 ) and the redaction was left without
940 * its safety net there. Booleans and null stay out on purpose: as text
941 * they are '1' and '', which would match half the file.
942 */
943 return ( is_int( $value ) || is_float( $value ) ) ? array( (string) $value ) : array();
944 }
945
946 /**
947 * Values in force of the readable constants, the ones kept in the copy
948 *
949 * @since 2.11.8
950 *
951 * @return string[]
952 */
953 private function readable_values_in_force() {
954 $values = array();
955
956 foreach ( self::$readable_constants as $name ) {
957 if ( defined( $name ) ) {
958 $values = array_merge( $values, self::string_leaves( constant( $name ) ) );
959 }
960 }
961
962 return $values;
963 }
964
965 /**
966 * Replace the values a root .htaccess can carry as credentials
967 *
968 * The .htaccess is not a secrets file, but it can hold a few: an
969 * environment variable handed to PHP with SetEnv, an Authorization header
970 * set for a backend, or a php_value with a password, a key, a licence or a
971 * session store address with its auth in it. The directive and its name
972 * stay, the value goes. Line based, which is how Apache reads it too. Since
973 * the cross review of 2.11.8 also any request or response header whose name
974 * reads like a credential (X-Api-Key, a cookie, a signature) and a
975 * RewriteCond that compares against key=, token= or the like, the way a
976 * staging site is opened with a secret in the query string. What it cannot
977 * see is a credential written in any other shape.
978 *
979 * @since 2.11.8
980 *
981 * @param string $content Normalized .htaccess content.
982 * @return string
983 */
984 private function redact_server_secrets( $content ) {
985 $redacted = preg_replace(
986 array(
987 '/^([ \t]*SetEnv[ \t]+\S+[ \t]+)\S.*$/mi',
988 '/^([ \t]*(?:RequestHeader|Header)[ \t]+(?:always[ \t]+)?\S+[ \t]+[\w-]*(?:auth|key|token|secret|pass|cookie|sig)[\w-]*[ \t]+)\S.*$/mi',
989 '/^([ \t]*php_(?:admin_)?value[ \t]+\S*(?:pass|pw|secret|key|token|licen|auth|save_path)\S*[ \t]+)\S.*$/mi',
990 '/^([ \t]*RewriteCond[ \t]+\S+[ \t]+)\S*(?:key|token|secret|pass|auth|sig)[\w-]*=\S*/mi',
991 ),
992 '${1}' . self::REDACTED_MARKER,
993 (string) $content
994 );
995
996 return ( null === $redacted ) ? '' : $redacted;
997 }
998
999 /**
1000 * Promote a per-site baseline to the network record before dropping it
1001 *
1002 * Up to 2.11.2 the baseline was a per-site option, so on a network every
1003 * site kept its own copy of the same two files. Those copies go, but what a
1004 * copy records is which version of the file the owner approved, and that
1005 * has to survive: rebuilding the baseline from disk would take whatever is
1006 * there right now as approved, so a wp-config.php modified and still
1007 * awaiting review would be blessed in silence.
1008 *
1009 * WHICH copy becomes the network record is not a detail, and 2.11.3 got it
1010 * wrong. This runs from the scan, under wp-cron, on whichever site gets
1011 * traffic first, and the sweep from the main site can be hours away because
1012 * it waits for a network administrator to open a dashboard. So on a network
1013 * with traffic spread around, the record of the whole installation was
1014 * whatever the first subsite to scan happened to hold.
1015 *
1016 * That is harmless while every copy agrees, which is the ordinary case. The
1017 * reason they can disagree is the very thing 2.11.3 fixed: until then,
1018 * approving a change to wp-config.php took manage_options, which on a
1019 * network the administrator of every subsite holds. If a change was
1020 * approved on some subsite while the main site still had it pending review,
1021 * promoting that subsite's copy retires a warning nobody decided to retire.
1022 *
1023 * Hence the order, file by file: what the network record already holds
1024 * wins, then the main site, then the site this runs on. Between the copies,
1025 * the main site beats a subsite, which is @calzbert's point, reported after
1026 * reading the 2.11.3 diff.
1027 *
1028 * What this does NOT protect, said plainly because an earlier wording
1029 * claimed more: if the network record already holds a file, that entry
1030 * wins, even when it was written from disk by the .htaccess writer on
1031 * init:20 while a third-party edit was pending review. What survives is a
1032 * file the network record does not hold yet, which is the wp-config.php
1033 * case that 2.11.3 lost. The .htaccess case is pre-existing and needs the
1034 * writers to pass their before-hash, see update_critical_file_baseline().
1035 *
1036 * @since 2.11.4
1037 *
1038 * @param array|null $per_site Baseline stored for the site this runs on.
1039 * @return bool True when the network record covers everything the per-site
1040 * copy had, which is the only case where dropping it is safe.
1041 */
1042 private function promote_per_site_baseline( $per_site ) {
1043 $network = get_site_option( self::BASELINE_OPTION, array() );
1044
1045 if ( ! is_array( $network ) ) {
1046 $network = array();
1047 }
1048
1049 /*
1050 * Three sources, filled in one from another, file by file. It used to
1051 * be all or nothing: if the network record existed at all, this
1052 * returned at once and the caller dropped the per-site copy anyway.
1053 *
1054 * That looked safe and was not, because the network record can be born
1055 * holding ONE of the two files. maybe_sync_server_files() runs on init
1056 * and rewrites the root .htaccess by itself, and the writer calls
1057 * update_critical_file_baseline( '.htaccess' ), which creates the
1058 * network option with that single entry. init runs before admin_init,
1059 * so on a network on Apache this is the ordinary order of an update,
1060 * not a race: the cleanup then found the option "already there", kept
1061 * nothing, and deleted the per-site copies that held the approved
1062 * record of wp-config.php. The next scan met a file it had never seen
1063 * and stored whatever was on disk as approved, which is the silent
1064 * blessing this whole function exists to prevent. Reproduced on the
1065 * Multisite install on 10 sep 2026, found by a cross review.
1066 *
1067 * Order of authority: what the network already says wins, then the main
1068 * site, then the site this runs on. Nothing is ever overwritten and
1069 * nothing is dropped for being late.
1070 */
1071 $sources = array( $network );
1072
1073 if ( ! is_main_site() ) {
1074 $from_main = get_blog_option( get_main_site_id(), self::BASELINE_OPTION, null );
1075
1076 if ( is_array( $from_main ) ) {
1077 $sources[] = $from_main;
1078 }
1079 }
1080
1081 if ( is_array( $per_site ) ) {
1082 $sources[] = $per_site;
1083 }
1084
1085 $merged = array();
1086
1087 foreach ( $sources as $source ) {
1088 foreach ( $source as $filename => $data ) {
1089 if ( isset( $merged[ $filename ] ) || ! is_array( $data ) || ! isset( $data['hash'] ) ) {
1090 continue;
1091 }
1092
1093 // Only the content carries secrets; the hash and the size,
1094 // which are what say "this is the version that was approved",
1095 // go over untouched.
1096 if ( isset( $data['content'] ) && is_string( $data['content'] ) ) {
1097 $data['content'] = $this->baseline_content( $filename, $data['content'] );
1098 }
1099
1100 $merged[ $filename ] = $data;
1101 }
1102 }
1103
1104 if ( array_diff_key( $merged, $network ) ) {
1105 $this->write_baseline( $merged );
1106 }
1107
1108 if ( ! is_array( $per_site ) ) {
1109 return true;
1110 }
1111
1112 /*
1113 * Is every file this copy had a record of now on the network record?
1114 * Only then may the caller drop it. And the question is asked of what
1115 * is STORED, not of $merged, which is only what this request MEANT to
1116 * store. Asking $merged makes the answer true by construction, because
1117 * the copy is one of the sources above, so the guard could never fire
1118 * and redact_in_place() in the caller was unreachable code.
1119 *
1120 * The write does not always land, and the case that matters is not a
1121 * broken database, it is the same race as the bug this function fixes.
1122 * On a network updating from 2.11.2 the network option does not exist
1123 * yet, so update_network_option() takes the $old_value === false branch
1124 * and delegates to add_network_option() (wp-includes/option.php:2434).
1125 * If another request created the option in between, that call either
1126 * returns false without writing (option.php:2201) or, when this process
1127 * still holds "does not exist" in its own notoptions cache, skips the
1128 * check and INSERTs a second row: wp_sitemeta has no unique index on
1129 * meta_key, so the record ends up duplicated and get_network_option()
1130 * hands back whichever row comes first. Reproduced on the Multisite
1131 * install on 10 sep 2026, with the .htaccess writer of init:20 racing a
1132 * promotion: two rows, the approved hash of wp-config.php out of reach,
1133 * and the per-site copy deleted all the same. Found by a cross review.
1134 *
1135 * Both cache keys go before rereading, and that is not belt and braces.
1136 * add_network_option() caches the value it believes it wrote
1137 * (option.php:2221), so a plain read hands back the very array that did
1138 * not survive; and a stale notoptions would answer "no such option"
1139 * without touching the database, which reads as "nothing is covered".
1140 * Measured: without dropping the cache this guard still returns true.
1141 */
1142 $network_id = get_current_network_id();
1143 wp_cache_delete( $network_id . ':' . self::BASELINE_OPTION, 'site-options' );
1144 wp_cache_delete( $network_id . ':notoptions', 'site-options' );
1145
1146 $stored = get_site_option( self::BASELINE_OPTION, array() );
1147
1148 if ( ! is_array( $stored ) ) {
1149 return false;
1150 }
1151
1152 foreach ( $per_site as $filename => $data ) {
1153 if ( is_array( $data ) && isset( $data['hash'] ) && ! isset( $stored[ $filename ] ) ) {
1154 return false;
1155 }
1156 }
1157
1158 return true;
1159 }
1160
1161 /**
1162 * Strip the secrets from a per-site copy that cannot be dropped yet
1163 *
1164 * The copy stays because it holds the only record of an approved file, but
1165 * what it must not keep for one more minute is the database password and
1166 * the eight keys and salts. The two things are separable and this is where
1167 * they get separated.
1168 *
1169 * @since 2.11.4
1170 *
1171 * @param array $per_site Baseline stored for the current site.
1172 * @return void
1173 */
1174 private function redact_in_place( $per_site ) {
1175 $changed = false;
1176
1177 foreach ( $per_site as $filename => $data ) {
1178 if ( ! is_array( $data ) || ! isset( $data['content'] ) || ! is_string( $data['content'] ) ) {
1179 continue;
1180 }
1181
1182 $safe = $this->baseline_content( $filename, $data['content'] );
1183
1184 if ( $safe !== $data['content'] ) {
1185 $per_site[ $filename ]['content'] = $safe;
1186 $changed = true;
1187 }
1188 }
1189
1190 if ( $changed ) {
1191 update_option( self::BASELINE_OPTION, $per_site );
1192 }
1193 }
1194
1195 /**
1196 * Network option recording the version whose results cleanup walked the network
1197 *
1198 * @since 2.11.8
1199 */
1200 const RESULTS_SWEEP_OPTION = 'vigilante_results_sweep';
1201
1202 /**
1203 * Clean the stored scan results of every site of the network, once per version
1204 *
1205 * redact_stored_results() runs per site from admin_init and from the scan,
1206 * so a subsite with the module off whose dashboard nobody opens kept the
1207 * lines of wp-config.php its last scan stored, with whatever that version
1208 * failed to redact. Same gap 2.11.3 and 2.11.4 closed for the baseline copy;
1209 * found for the results by the cross review of 2.11.8. It runs from the
1210 * network sweep, for a network administrator on the main site, and has its
1211 * own marker because the baseline sweep is already done on every network
1212 * that updated through 2.11.4.
1213 *
1214 * @since 2.11.8
1215 */
1216 private function maybe_sweep_network_results() {
1217 if ( VIGILANTE_VERSION === get_site_option( self::RESULTS_SWEEP_OPTION ) ) {
1218 return;
1219 }
1220
1221 update_site_option( self::RESULTS_SWEEP_OPTION, VIGILANTE_VERSION );
1222
1223 $site_ids = get_sites(
1224 array(
1225 'fields' => 'ids',
1226 'number' => 0,
1227 'network_id' => get_current_network_id(),
1228 'update_site_meta_cache' => false,
1229 )
1230 );
1231
1232 foreach ( $site_ids as $site_id ) {
1233 switch_to_blog( $site_id );
1234 $this->redact_stored_results();
1235 restore_current_blog();
1236 }
1237 }
1238
1239 /**
1240 * The diff of a shared file as a site that does not own it gets it
1241 *
1242 * No lines, and a flag the screens read to say where the lines are.
1243 *
1244 * @since 2.11.8
1245 *
1246 * @return array
1247 */
1248 public static function network_only_diff() {
1249 return array(
1250 'added' => array(),
1251 'removed' => array(),
1252 'unavailable' => true,
1253 'network' => true,
1254 );
1255 }
1256
1257 /**
1258 * Take out of the last stored scan what the baseline copy no longer keeps
1259 *
1260 * The results of the last scan are an option of each site, and the diff of
1261 * a critical file travels inside them line by line, redacted the way the
1262 * version that ran the scan redacted. Until 2.11.7 that let FTP_PASS and
1263 * friends through, and on a network every subsite with the module on kept
1264 * its own copy of the lines. This runs once per version with the rest of
1265 * the cleanup:
1266 *
1267 * - Where the shared files do not belong to this site, no line is kept.
1268 * - Lines of wp-config.php are dropped. A single line cannot be read as
1269 * PHP reliably (half a heredoc is just words), and the next scan rebuilds
1270 * them from the whole file.
1271 * - Lines of .htaccess are directives, one per line, and are redacted in
1272 * place.
1273 *
1274 * @since 2.11.8
1275 */
1276 private function redact_stored_results() {
1277 $results = get_option( 'vigilante_last_integrity_results' );
1278
1279 if ( ! is_array( $results ) || empty( $results['modified'] ) || ! is_array( $results['modified'] ) ) {
1280 return;
1281 }
1282
1283 $owns = Vigilante_Settings::owns_shared_files();
1284 $changed = false;
1285
1286 foreach ( $results['modified'] as $index => $item ) {
1287 if ( ! is_array( $item ) || 'critical_config' !== ( $item['type'] ?? '' ) || ! isset( $item['diff'] ) || ! is_array( $item['diff'] ) ) {
1288 continue;
1289 }
1290
1291 if ( ! $owns ) {
1292 if ( empty( $item['diff']['network'] ) ) {
1293 $results['modified'][ $index ]['diff'] = self::network_only_diff();
1294 $changed = true;
1295 }
1296 continue;
1297 }
1298
1299 if ( 'wp-config.php' === ( $item['file'] ?? '' ) ) {
1300 if ( ! empty( $item['diff']['added'] ) || ! empty( $item['diff']['removed'] ) ) {
1301 $results['modified'][ $index ]['diff'] = array(
1302 'added' => array(),
1303 'removed' => array(),
1304 'unavailable' => true,
1305 'rescan' => true,
1306 );
1307 $changed = true;
1308 }
1309 continue;
1310 }
1311
1312 foreach ( array( 'added', 'removed' ) as $side ) {
1313 if ( empty( $item['diff'][ $side ] ) || ! is_array( $item['diff'][ $side ] ) ) {
1314 continue;
1315 }
1316
1317 foreach ( $item['diff'][ $side ] as $line_index => $line ) {
1318 if ( ! is_array( $line ) || ! isset( $line['content'] ) || ! is_string( $line['content'] ) ) {
1319 continue;
1320 }
1321
1322 $safe = $this->redact_server_secrets( $line['content'] );
1323
1324 if ( $safe !== $line['content'] ) {
1325 $results['modified'][ $index ]['diff'][ $side ][ $line_index ]['content'] = $safe;
1326 $changed = true;
1327 }
1328 }
1329 }
1330 }
1331
1332 if ( $changed ) {
1333 update_option( 'vigilante_last_integrity_results', $results );
1334 }
1335 }
1336
1337 /**
1338 * Clean up what earlier versions stored, wherever they stored it
1339 *
1340 * Two jobs, and the second one only exists on a network.
1341 *
1342 * The first: versions up to 2.11.1 kept the contents of wp-config.php in
1343 * the baseline, credentials included, so what is already on disk is
1344 * redacted in place.
1345 *
1346 * The second: up to 2.11.2 that baseline was a per-site option, so on a
1347 * network every site had its own copy of the same file. This runs per site
1348 * and removes that copy, because the baseline now lives in a single
1349 * network option. Doing it here is what makes the cleanup reach a site
1350 * whose dashboard nobody ever opens: this method is called from the scan
1351 * as well as from admin_init, and the scan runs on every site through
1352 * wp-cron with front-end traffic alone.
1353 *
1354 * The gate option stays per site on purpose. It records that THIS site has
1355 * been cleaned, which is exactly the per-site fact being tracked.
1356 *
1357 * @since 2.11.2
1358 */
1359 public function maybe_redact_stored_baseline() {
1360 if ( VIGILANTE_VERSION === get_option( self::BASELINE_REDACTION_OPTION ) ) {
1361 return;
1362 }
1363
1364 /*
1365 * The per-site copy left behind by 2.11.2 and earlier. On a network it
1366 * holds the database password and the eight keys and salts, so it goes,
1367 * but never before what it records has been carried over:
1368 * promote_per_site_baseline() explains why the record has to outlive
1369 * the copy, and which copy wins when they disagree. Measured on the
1370 * Multisite install while writing 2.11.3: without that, the first scan
1371 * after the migration reported zero modified files where it had to
1372 * report one.
1373 */
1374 $pending = false;
1375
1376 if ( is_multisite() ) {
1377 $per_site = get_option( self::BASELINE_OPTION, null );
1378
1379 if ( null !== $per_site ) {
1380 if ( $this->promote_per_site_baseline( $per_site ) ) {
1381 delete_option( self::BASELINE_OPTION );
1382 } elseif ( is_array( $per_site ) ) {
1383 // Something this copy recorded is not on the network record
1384 // yet, so it does not go: it is the only evidence of what
1385 // was approved. The secrets do go, right now, because that
1386 // part cannot wait for the next pass.
1387 $this->redact_in_place( $per_site );
1388 $pending = true;
1389 }
1390 }
1391 }
1392
1393 $baseline = $this->read_baseline();
1394
1395 if ( is_array( $baseline ) ) {
1396 $changed = false;
1397
1398 foreach ( $baseline as $filename => $data ) {
1399 if ( ! is_array( $data ) || ! isset( $data['content'] ) || ! is_string( $data['content'] ) ) {
1400 continue;
1401 }
1402
1403 $safe = $this->baseline_content( $filename, $data['content'] );
1404
1405 if ( $safe !== $data['content'] ) {
1406 $baseline[ $filename ]['content'] = $safe;
1407 $changed = true;
1408 }
1409 }
1410
1411 if ( $changed ) {
1412 $this->write_baseline( $baseline );
1413 }
1414 }
1415
1416 $this->redact_stored_results();
1417
1418 /*
1419 * The gate does not close while a per-site copy is still waiting to be
1420 * promoted. Closing it would end the retries for a whole version: the
1421 * copy would sit there unread, the file it records would be missing
1422 * from the network record, and the next scan would take whatever is on
1423 * disk as approved. Not closing it is not free, though, and the first
1424 * wording here said "one option read": measured cold on the Multisite
1425 * install, it is 6 SQL queries per admin request against 0 with the gate
1426 * closed, admin-ajax.php and the heartbeat included, two of them from the
1427 * cache invalidation in promote_per_site_baseline(). Acceptable only
1428 * because it converges: the stuck case this guards against resolves on
1429 * the next pass that gets its write through.
1430 */
1431 if ( ! $pending ) {
1432 update_option( self::BASELINE_REDACTION_OPTION, VIGILANTE_VERSION, false );
1433 }
1434 }
1435
1436 /**
1437 * Sweep the whole network once, so it does not wait for each site's cron
1438 *
1439 * The per-site cleanup above reaches a site when that site runs a scan or
1440 * someone opens its dashboard, which on a quiet subsite can take a while.
1441 * This walks every site once and gets it over with, and its marker is a
1442 * network option so it does not repeat per site.
1443 *
1444 * Runs only on the main site of the network, where a network administrator
1445 * works, and only there does it cost anything.
1446 *
1447 * @since 2.11.3
1448 */
1449 public function maybe_sweep_network_baselines() {
1450 if ( ! is_multisite() || ! is_main_site() ) {
1451 return;
1452 }
1453
1454 /*
1455 * A network administrator, and nobody else. admin_init fires for any
1456 * logged-in visitor, a subscriber opening their own profile included,
1457 * and this walks every site of the network writing to each one. What it
1458 * removes is stale data that rebuilds itself, so the harm is small, but
1459 * an action over the whole network belongs to whoever administers the
1460 * network. The surface inventory cannot see this: it reads wp_ajax_*,
1461 * admin_post_* and REST routes, and a hook on admin_init is outside its
1462 * coverage by construction, which is exactly the blind spot written
1463 * down as rule 23.
1464 *
1465 * The per-site cleanup is deliberately not gated the same way: it also
1466 * runs from the scan, under wp-cron with no user at all, and it only
1467 * touches the site it runs on.
1468 */
1469 if ( ! current_user_can( 'manage_network_options' ) ) {
1470 return;
1471 }
1472
1473 $this->maybe_sweep_network_results();
1474
1475 $marker = get_site_option( self::BASELINE_SWEEP_OPTION );
1476
1477 /*
1478 * Two markers, because there are two different things to remember and
1479 * 2.11.3 only remembered one of them.
1480 *
1481 * The walk is marked BEFORE it starts, on purpose: on a very large
1482 * network it may not finish inside one request, and repeating it on
1483 * every admin page load would be worse than leaving the rest to each
1484 * site's own scan. But 2.11.3 wrote VIGILANTE_VERSION there, so an
1485 * interrupted walk was retried by the next release, which was the only
1486 * thing that ever finished it. Writing a fixed literal instead, as the
1487 * first draft of 2.11.4 did, stopped the pointless rearming and took
1488 * that retry away with it: a walk cut short would never be resumed by
1489 * any version. And "each site's own scan cleans the rest" only holds
1490 * where the module is on; with it off, the cleanup is registered under
1491 * is_admin() alone, so a subsite nobody opens is exactly what the sweep
1492 * exists for. Found by a cross review on 10 sep 2026.
1493 *
1494 * So: the running version means "started here and did not finish", and
1495 * the migration literal means "finished, never again".
1496 */
1497 if ( self::BASELINE_SWEEP_MIGRATION === $marker ) {
1498 return;
1499 }
1500
1501 if ( VIGILANTE_VERSION === $marker ) {
1502 return;
1503 }
1504
1505 update_site_option( self::BASELINE_SWEEP_OPTION, VIGILANTE_VERSION );
1506
1507 // Only this network. WP_Site_Query filters by network solely when
1508 // network_id is given, so on a multi-network install the walk would
1509 // otherwise reach the sites of other networks and promote their copies
1510 // into this network's record (get_current_network_id() below does not
1511 // change with switch_to_blog()).
1512 $site_ids = get_sites(
1513 array(
1514 'fields' => 'ids',
1515 'number' => 0,
1516 'network_id' => get_current_network_id(),
1517 'update_site_meta_cache' => false,
1518 )
1519 );
1520
1521 $pending = 0;
1522
1523 foreach ( $site_ids as $site_id ) {
1524 switch_to_blog( $site_id );
1525
1526 $per_site = get_option( self::BASELINE_OPTION, null );
1527
1528 if ( null !== $per_site ) {
1529 // Same care as the per-site cleanup, and the same helper, so
1530 // the two paths cannot drift apart the way they nearly did.
1531 if ( $this->promote_per_site_baseline( $per_site ) ) {
1532 delete_option( self::BASELINE_OPTION );
1533 } elseif ( is_array( $per_site ) ) {
1534 $this->redact_in_place( $per_site );
1535 $pending++;
1536 }
1537 }
1538
1539 restore_current_blog();
1540 }
1541
1542 // Finished, and with nothing left behind, so it never has to run again
1543 // in any version. A site whose copy could not be promoted keeps the
1544 // marker on the running version instead, which is what gets the walk
1545 // retried by the next release.
1546 if ( ! $pending ) {
1547 update_site_option( self::BASELINE_SWEEP_OPTION, self::BASELINE_SWEEP_MIGRATION );
1548 }
1549 }
1550
1551 /**
1552 * Critical root files to monitor against a stored baseline.
1553 * These files have no official WordPress.org checksum because their
1554 * content is unique per installation.
1555 *
1556 * @var array
1557 */
1558 private $critical_root_files = array(
1559 'wp-config.php',
1560 '.htaccess',
1561 );
1562
1563 /**
1564 * Vigilante markers used in wp-config.php (constants block).
1565 *
1566 * @var array
1567 */
1568 private $wpconfig_markers = array(
1569 array( '/* BEGIN Vigilante Security Constants */', '/* END Vigilante Security Constants */' ),
1570 array( '/* BEGIN AyudaWP Security Constants */', '/* END AyudaWP Security Constants */' ),
1571 );
1572
1573 /**
1574 * Vigilante marker for commented-out original constants in wp-config.php.
1575 *
1576 * @var string
1577 */
1578 private $wpconfig_original_marker = '// [VIGILANTE_ORIGINAL] ';
1579
1580 /**
1581 * Vigilante markers used in .htaccess (firewall + security headers).
1582 *
1583 * @var array
1584 */
1585 private $htaccess_markers = array(
1586 array( '# BEGIN Vigilante Protection', '# END Vigilante Protection' ),
1587 array( '# BEGIN Vigilante Security Headers', '# END Vigilante Security Headers' ),
1588 );
1589
1590 /**
1591 * Whether the core is being compared with the English checksums because
1592 * WordPress.org has none for the language of the site. Set by
1593 * get_core_checksums().
1594 *
1595 * @var bool
1596 */
1597 private $core_checksums_are_english = false;
1598
1599 /**
1600 * Core files known to produce false positives in checksum comparison.
1601 * These are skipped during core scanning: the readme and the license vary
1602 * by locale and are not code. version.php used to be here too, and is now
1603 * compared (see matches_core_of_other_locale()).
1604 *
1605 * @var array
1606 */
1607 private $core_known_false_positives = array(
1608 'readme.html',
1609 'license.txt',
1610 'licencia.txt',
1611 );
1612
1613 /**
1614 * Plugin files known to produce false positives in checksum comparison.
1615 * Readme files frequently differ between WordPress.org API checksums and
1616 * the actual installed version due to encoding, line endings, or locale.
1617 *
1618 * @var array
1619 */
1620 private $plugin_known_false_positives = array(
1621 'readme.txt',
1622 'readme.md',
1623 );
1624
1625 /**
1626 * Legitimate non-PHP files commonly found in WordPress root.
1627 * These are reported as 'additional' (informational), not suspicious.
1628 * Dotfiles (e.g. .htaccess) are skipped entirely by the root scanner.
1629 *
1630 * @var array
1631 */
1632 private $known_safe_root_files = array(
1633 'robots.txt',
1634 'security.txt',
1635 'humans.txt',
1636 'llms.txt',
1637 'llms-full.txt',
1638 'ads.txt',
1639 'app-ads.txt',
1640 'favicon.ico',
1641 'favicon.png',
1642 'favicon.svg',
1643 'apple-touch-icon.png',
1644 'apple-touch-icon-precomposed.png',
1645 'sitemap.xml',
1646 'sitemap_index.xml',
1647 'bingsiteauth.xml',
1648 'livesearchsiteauth.xml',
1649 'google-site-verification.html',
1650 'php.ini',
1651 // PHP error logs commonly created by managed hosting (SiteGround, Hostinger, cPanel).
1652 // Not executable; reported as "additional" instead of "suspicious".
1653 'php_errorlog',
1654 'error_log',
1655 );
1656
1657 /**
1658 * Legacy WordPress core files removed from newer versions but kept on
1659 * existing installs to prevent breakage. These are dead code, not malware.
1660 * Marked as 'extra' (additional) instead of 'suspicious' with advice to delete.
1661 *
1662 * @see https://core.trac.wordpress.org/ticket/48540
1663 * @see https://core.trac.wordpress.org/ticket/18384
1664 * @var array
1665 */
1666 private $legacy_core_root_files = array(
1667 'wp-feed.php',
1668 'wp-rss.php',
1669 'wp-rss2.php',
1670 'wp-rdf.php',
1671 'wp-atom.php',
1672 'wp-commentsrss2.php',
1673 'wp-pass.php',
1674 'wp-register.php',
1675 );
1676
1677 /**
1678 * Constructor
1679 *
1680 * @param Vigilante_Settings $settings Settings instance.
1681 * @param Vigilante_Database|null $database Database instance.
1682 * @param Vigilante_Activity_Log|null $activity_log Activity log instance.
1683 */
1684 public function __construct( $settings, $database = null, $activity_log = null ) {
1685 $this->settings = $settings;
1686 $this->database = $database;
1687 $this->activity_log = $activity_log;
1688 $this->options = $settings ? $settings->get_section( 'file_integrity' ) : array();
1689 $this->wp_version = get_bloginfo( 'version' );
1690 $this->ignored_files = get_option( 'vigilante_ignored_files', array() );
1691 }
1692
1693 /**
1694 * Register the hooks of the scanner itself
1695 *
1696 * Until 2.11.4 all of this lived in the constructor, and the constructor is
1697 * called from a dozen places: the module gate, the activator, the hook that
1698 * runs after Vigilant writes a watched file, and the admin handlers that
1699 * only want the class as a tool. Every
1700 * one of them registered these hooks again, and one runs during admin_init
1701 * itself. Registering apart from constructing means a `new` is only a
1702 * `new`, and it is what lets the cleanup below stand on its own.
1703 *
1704 * @since 2.11.4
1705 */
1706 public function init_hooks() {
1707 // Schedule automated scans only if options available
1708 if ( ! empty( $this->options['auto_scan'] ) ) {
1709 add_action( 'vigilante_file_integrity_scan', array( $this, 'run_scheduled_scan' ) );
1710 $this->schedule_scan();
1711 }
1712
1713 // Post-update verification: verify any just-updated plugin/theme against
1714 // WordPress.org immediately, and open a short grace window so the
1715 // scheduled scan does not raise false positives while wp.org is still
1716 // publishing the new version's checksums. Registered regardless of
1717 // auto_scan because it reacts to update events, not to the schedule.
1718 add_action( 'upgrader_process_complete', array( $this, 'on_upgrade_complete' ), 20, 2 );
1719 add_action( 'vigilante_fi_postupdate_verify', array( $this, 'run_postupdate_verify' ) );
1720 }
1721
1722 /**
1723 * Register the cleanup of what earlier versions stored, module on or off
1724 *
1725 * These two are not integrity monitoring. They take out of the database
1726 * something the plugin stored and should not have, which is the copy of
1727 * wp-config.php carrying the database password and the eight keys and
1728 * salts. Whoever switched the module off did not decide to keep that, and
1729 * for that person the cleanup matters more, not less: they are not going
1730 * to pass through the scanner again.
1731 *
1732 * Until 2.11.4 these were registered in the constructor, so they only ran
1733 * where the module was on. A site with the module off kept the credentials
1734 * with 2.11.3 installed, and a network whose main site had it off lost the
1735 * sweep too, which was the one path that reached the sites nobody visits.
1736 * Reported by @calzbert after reading the 2.11.3 diff.
1737 *
1738 * On admin_init because that is where the baseline is looked at, and it
1739 * does one option read per admin request until it has run once.
1740 *
1741 * @since 2.11.4
1742 */
1743 public function init_cleanup_hooks() {
1744 add_action( 'admin_init', array( $this, 'maybe_redact_stored_baseline' ) );
1745 add_action( 'admin_init', array( $this, 'maybe_sweep_network_baselines' ) );
1746 add_action( 'admin_init', array( $this, 'maybe_claim_owned_blocks_on_admin' ) );
1747 }
1748
1749 /**
1750 * Check if scan time limit has been exceeded
1751 *
1752 * @return bool True if time exceeded.
1753 */
1754 private function is_time_exceeded() {
1755 if ( 0 === $this->scan_start_time ) {
1756 return false;
1757 }
1758 return ( microtime( true ) - $this->scan_start_time ) > $this->max_scan_time;
1759 }
1760
1761 /**
1762 * Schedule automated scans
1763 */
1764 private function schedule_scan() {
1765 $frequency = $this->options['scan_frequency'] ?? 'daily';
1766
1767 if ( ! wp_next_scheduled( 'vigilante_file_integrity_scan' ) ) {
1768 wp_schedule_event( time(), $frequency, 'vigilante_file_integrity_scan' );
1769 }
1770 }
1771
1772 /**
1773 * Run a scheduled scan
1774 */
1775 public function run_scheduled_scan() {
1776 $results = $this->run_scan();
1777
1778 // Store last scan time
1779 update_option( 'vigilante_last_integrity_scan', time() );
1780 update_option( 'vigilante_last_integrity_results', $results );
1781 }
1782
1783 /**
1784 * React to a completed plugin/theme update (upgrader_process_complete).
1785 *
1786 * Opens a short grace window for each updated slug (so the scheduled scan
1787 * skips it and the checksum cache is bypassed) and schedules an immediate
1788 * verification against WordPress.org. This stops the post-update "files
1789 * don't match WordPress.org" false positives and, when WP-Cron is healthy,
1790 * verifies the update against WordPress.org right away instead of waiting
1791 * for the next scheduled scan. The grace window is intentionally short (30
1792 * minutes) so that if WP-Cron never fires the verification, the normal scan
1793 * resumes for the slug rather than leaving it unscanned.
1794 *
1795 * Runs as the old plugin code with the new files already on disk, so slugs
1796 * are read from $hook_extra, not from in-memory version constants.
1797 *
1798 * @param WP_Upgrader|mixed $upgrader Upgrader instance (unused).
1799 * @param array $hook_extra Update context.
1800 */
1801 public function on_upgrade_complete( $upgrader, $hook_extra ) {
1802 unset( $upgrader );
1803 if ( ! is_array( $hook_extra ) || 'update' !== ( $hook_extra['action'] ?? '' ) ) {
1804 return;
1805 }
1806
1807 $type = $hook_extra['type'] ?? '';
1808 $targets = array(); // type => list of slugs.
1809
1810 if ( 'plugin' === $type ) {
1811 $files = array();
1812 if ( ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
1813 $files = $hook_extra['plugins'];
1814 } elseif ( ! empty( $hook_extra['plugin'] ) ) {
1815 $files = array( $hook_extra['plugin'] );
1816 }
1817 foreach ( $files as $file ) {
1818 $slug = dirname( (string) $file );
1819 // Vigilant itself is verified immediately (not after 90 s) by
1820 // Vigilante_Self_Integrity::handle_upgrader() when the
1821 // self-check is on; do not also open a grace window for it.
1822 // Compared with the folder it really lives in, not the literal
1823 // slug, so a renamed folder is skipped the same way.
1824 if ( dirname( VIGILANTE_PLUGIN_BASENAME ) === $slug && Vigilante_Self_Integrity::is_on() ) {
1825 continue;
1826 }
1827 if ( '.' !== $slug && '' !== $slug ) {
1828 $targets['plugin'][] = $slug;
1829 }
1830 }
1831 } elseif ( 'theme' === $type && ! empty( $hook_extra['themes'] ) && is_array( $hook_extra['themes'] ) ) {
1832 foreach ( $hook_extra['themes'] as $slug ) {
1833 $slug = (string) $slug;
1834 if ( '' !== $slug ) {
1835 $targets['theme'][] = $slug;
1836 }
1837 }
1838 }
1839
1840 if ( empty( $targets ) ) {
1841 return;
1842 }
1843
1844 // Open a short grace window per slug (30 minutes; closed earlier once the
1845 // verifier runs). Kept short on purpose: with WP-Cron disabled the
1846 // verifier never fires, so a longer window would leave a just-updated
1847 // slug unscanned. After it expires the normal scan resumes.
1848 foreach ( $targets as $t => $slugs ) {
1849 foreach ( array_unique( $slugs ) as $slug ) {
1850 set_transient( 'vigilante_fi_grace_' . $t . '_' . md5( $slug ), 1, 30 * MINUTE_IN_SECONDS );
1851 }
1852 }
1853
1854 // Verify shortly after the update settles. A single delayed event keeps
1855 // the heavy work out of the update request itself.
1856 if ( ! wp_next_scheduled( 'vigilante_fi_postupdate_verify', array( $targets ) ) ) {
1857 wp_schedule_single_event( time() + 90, 'vigilante_fi_postupdate_verify', array( $targets ) );
1858 }
1859 }
1860
1861 /**
1862 * Immediate post-update verification callback (vigilante_fi_postupdate_verify).
1863 *
1864 * @param array $targets type => list of slugs.
1865 */
1866 public function run_postupdate_verify( $targets ) {
1867 if ( ! is_array( $targets ) ) {
1868 return;
1869 }
1870 foreach ( $targets as $type => $slugs ) {
1871 if ( 'plugin' !== $type && 'theme' !== $type ) {
1872 continue;
1873 }
1874 foreach ( array_unique( (array) $slugs ) as $slug ) {
1875 $this->verify_updated_slug( $type, (string) $slug );
1876 }
1877 }
1878 }
1879
1880 /**
1881 * Verify a single just-updated plugin/theme against fresh wp.org checksums.
1882 *
1883 * The grace window forces get_*_checksums() to fetch a live manifest, so this
1884 * never compares against a manifest cached during wp.org's propagation lag.
1885 * Outcomes: checksums not published yet => leave the window to expire and let
1886 * the next scan re-check; all files match => close the window early; a file
1887 * matches no published hash => a genuine mismatch (right after a legit update
1888 * that points at a tampered package), logged as a warning, and the window is
1889 * closed so the finding also surfaces in the normal scan.
1890 *
1891 * @param string $type 'plugin' or 'theme'.
1892 * @param string $slug Slug.
1893 */
1894 private function verify_updated_slug( $type, $slug ) {
1895 $grace_key = 'vigilante_fi_grace_' . $type . '_' . md5( $slug );
1896
1897 // Defensive skip for the first 2.11.x -> 3.0.x update: the OLD code in
1898 // memory scheduled this event including Vigilant's own slug, and by the
1899 // time it fires the NEW code (this one) is running with the self-check
1900 // handling Vigilant on its own.
1901 if ( 'plugin' === $type && dirname( VIGILANTE_PLUGIN_BASENAME ) === $slug && Vigilante_Self_Integrity::is_on() ) {
1902 delete_transient( $grace_key );
1903 return;
1904 }
1905
1906 if ( 'plugin' === $type ) {
1907 if ( ! function_exists( 'get_plugins' ) ) {
1908 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1909 }
1910 $version = '';
1911 $name = $slug;
1912 foreach ( get_plugins() as $file => $data ) {
1913 if ( dirname( $file ) === $slug ) {
1914 $version = $data['Version'] ?? '';
1915 $name = $data['Name'] ?? $slug;
1916 break;
1917 }
1918 }
1919 $checksums = $this->get_plugin_checksums( $slug, $version );
1920 $base_dir = WP_PLUGIN_DIR . '/' . $slug;
1921 } else {
1922 $theme = wp_get_theme( $slug );
1923 if ( ! $theme->exists() ) {
1924 delete_transient( $grace_key );
1925 return;
1926 }
1927 $version = $theme->get( 'Version' );
1928 $name = $theme->get( 'Name' );
1929 $checksums = $this->get_theme_checksums( $slug, $version );
1930 $base_dir = $theme->get_stylesheet_directory();
1931 }
1932
1933 // Checksums not available yet (propagation lag): leave the grace window
1934 // to expire; the next scheduled scan re-verifies once wp.org publishes.
1935 if ( is_wp_error( $checksums ) || 'not_found' === $checksums || ! is_array( $checksums ) ) {
1936 if ( $this->activity_log ) {
1937 $this->activity_log->log(
1938 'file',
1939 'postupdate_pending',
1940 sprintf(
1941 /* translators: 1: Plugin or theme name. */
1942 __( 'Post-update verification pending for %1$s: WordPress.org has not published the new version checksums yet. It will be re-verified automatically.', 'vigilante' ),
1943 $name
1944 ),
1945 array(
1946 'type' => $type,
1947 'slug' => $slug,
1948 'version' => $version,
1949 ),
1950 'info'
1951 );
1952 }
1953 return;
1954 }
1955
1956 // Compare every shipped file against the fresh manifest.
1957 $mismatched = array();
1958 foreach ( $checksums as $file => $expected ) {
1959 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
1960 continue;
1961 }
1962 $path = $base_dir . '/' . $file;
1963 if ( $this->is_path_excluded( $path ) || $this->is_extension_excluded( $path ) ) {
1964 continue;
1965 }
1966 // Honor the user ignore list, exactly as run_scan()'s filter_ignored()
1967 // does, so the verifier never warns about a file the user silenced.
1968 $rel = $type . 's/' . $slug . '/' . $file;
1969 if ( in_array( $rel, (array) $this->ignored_files, true ) ) {
1970 continue;
1971 }
1972 if ( ! file_exists( $path ) ) {
1973 continue;
1974 }
1975 if ( ! $this->hash_matches_published( $path, $expected ) ) {
1976 $mismatched[] = $rel;
1977 }
1978 }
1979
1980 // Verified clean: close the grace window so normal scanning resumes.
1981 if ( empty( $mismatched ) ) {
1982 delete_transient( $grace_key );
1983 if ( $this->activity_log ) {
1984 $this->activity_log->log(
1985 'file',
1986 'postupdate_verified',
1987 sprintf(
1988 /* translators: 1: Plugin or theme name. */
1989 __( 'Post-update verification passed: %1$s matches the WordPress.org distribution.', 'vigilante' ),
1990 $name
1991 ),
1992 array(
1993 'type' => $type,
1994 'slug' => $slug,
1995 'version' => $version,
1996 ),
1997 'info'
1998 );
1999 }
2000 return;
2001 }
2002
2003 // Genuine mismatch right after a legitimate update: likely a tampered
2004 // package. Close the window so the normal scan also surfaces it, and log
2005 // a warning (Audit Alerts escalates warnings if configured).
2006 delete_transient( $grace_key );
2007 if ( $this->activity_log ) {
2008 $this->activity_log->log(
2009 'file',
2010 'postupdate_mismatch',
2011 sprintf(
2012 /* translators: 1: Number of files, 2: Plugin or theme name. */
2013 _n(
2014 'Post-update integrity check failed: %1$d file in %2$s does not match the WordPress.org distribution.',
2015 'Post-update integrity check failed: %1$d files in %2$s do not match the WordPress.org distribution.',
2016 count( $mismatched ),
2017 'vigilante'
2018 ),
2019 count( $mismatched ),
2020 $name
2021 ),
2022 array(
2023 'type' => $type,
2024 'slug' => $slug,
2025 'version' => $version,
2026 'files' => array_slice( $mismatched, 0, 50 ),
2027 ),
2028 'warning'
2029 );
2030 }
2031 }
2032
2033 /**
2034 * Run a full integrity scan
2035 *
2036 * @return array Scan results.
2037 */
2038 public function run_scan() {
2039 // Initialize scan timer
2040 $this->scan_start_time = microtime( true );
2041
2042 $results = array(
2043 'scanned' => 0,
2044 'ok' => 0,
2045 'modified' => array(),
2046 'missing' => array(),
2047 'suspicious' => array(),
2048 'extra' => array(),
2049 'new' => array(),
2050 'errors' => array(),
2051 'scan_time' => 0,
2052 'incomplete' => false,
2053 );
2054
2055 // Use settings from options page
2056 $options = is_array( $this->options ) ? $this->options : array();
2057
2058 // Vigilant self-check runs FIRST and exempt from the time budget:
2059 // ~60 small-file hashes cost < 50 ms and the guardian must never be
2060 // dropped by the budget on plugin-heavy sites. User exclusions do not
2061 // apply to it (see Vigilante_Self_Integrity::run_check()).
2062 if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
2063 require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
2064 }
2065 if ( Vigilante_Self_Integrity::is_on() ) {
2066 if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) {
2067 require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php';
2068 }
2069 $self = new Vigilante_Self_Integrity( $this->settings, $this->activity_log );
2070 $self_result = $self->run_check( 'scan' );
2071 // run_check() above updates this site's own state (status line and
2072 // the analyzer check keep working everywhere). The plugin files are
2073 // shared by the whole installation, so the self findings are folded
2074 // Self-protection has its own alert, and it does not travel in the
2075 // scan digest any more. That digest is governed by a notification
2076 // setting that can be switched off, and switching off "tell me about
2077 // changed files" was also switching off the alarm of the plugin
2078 // itself. So the findings stay out of the scan results (they have
2079 // their own block in File Integrity, with what each one means and how
2080 // to repair it) and a critical one sends its own email from here,
2081 // wherever the scan runs. maybe_send_self_alert() keeps it to the
2082 // site that owns the shared files and dedupes by set of findings.
2083 foreach ( (array) $self_result['findings'] as $self_finding ) {
2084 if ( 'critical' === ( $self_finding['severity'] ?? '' ) ) {
2085 $self->maybe_send_self_alert( $self_result['findings'], 'scan' );
2086 break;
2087 }
2088 }
2089 }
2090
2091 // What the time does not reach. A scan that runs out of time used to
2092 // say only that it was partial, and it starts from the beginning
2093 // every time, so on a large site the same plugins and themes were
2094 // left out on every scan with nobody told which. They are named.
2095 $not_checked = array(
2096 'areas' => array(),
2097 'plugins' => array(),
2098 'themes' => array(),
2099 );
2100
2101 // And the folders of the plugins and themes it did check, which is
2102 // what the tab offers to exclude so that a scan by hand reaches the
2103 // rest (see folders_to_exclude()).
2104 $checked_folders = array();
2105
2106 // Scan uploads for suspicious files FIRST (highest security priority)
2107 // PHP files in uploads are almost always malware
2108 if ( ! empty( $options['scan_uploads'] ) && ! $this->is_time_exceeded() ) {
2109 $upload_results = $this->scan_uploads();
2110 $results['suspicious'] = array_merge( $results['suspicious'], $upload_results['suspicious'] );
2111 $results['extra'] = array_merge( $results['extra'], $upload_results['extra'] );
2112 $results['errors'] = array_merge( $results['errors'], $upload_results['errors'] ?? array() );
2113 } elseif ( ! empty( $options['scan_uploads'] ) ) {
2114 $not_checked['areas'][] = 'uploads';
2115 }
2116
2117 // Scan core files
2118 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
2119 $core_results = $this->scan_core_files();
2120 $results = $this->merge_results( $results, $core_results );
2121 } elseif ( ! empty( $options['scan_core'] ) ) {
2122 $not_checked['areas'][] = 'core';
2123 }
2124
2125 // Scan root directory for non-core files (PHP = suspicious, others = additional)
2126 // Runs after core scan so checksums are already cached
2127 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
2128 $root_results = $this->scan_root_files();
2129 $results['suspicious'] = array_merge( $results['suspicious'], $root_results['suspicious'] );
2130 $results['extra'] = array_merge( $results['extra'], $root_results['extra'] );
2131 } elseif ( ! empty( $options['scan_core'] ) ) {
2132 $not_checked['areas'][] = 'core';
2133 }
2134
2135 // Files added to wp-admin or wp-includes, which the comparison with
2136 // the checksums never visits because the checksums do not name them.
2137 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
2138 $folder_results = $this->scan_core_folders();
2139 $results['suspicious'] = array_merge( $results['suspicious'], $folder_results['suspicious'] );
2140 $results['extra'] = array_merge( $results['extra'], $folder_results['extra'] );
2141 $results['errors'] = array_merge( $results['errors'], $folder_results['errors'] );
2142 } elseif ( ! empty( $options['scan_core'] ) ) {
2143 $not_checked['areas'][] = 'core';
2144 }
2145
2146 // Scan critical config files (wp-config.php, .htaccess) against stored baseline
2147 if ( ! empty( $options['scan_critical_config'] ) && ! $this->is_time_exceeded() ) {
2148 $critical_results = $this->scan_critical_root_files();
2149 $results['modified'] = array_merge( $results['modified'], $critical_results );
2150 } elseif ( ! empty( $options['scan_critical_config'] ) ) {
2151 $not_checked['areas'][] = 'config';
2152 }
2153
2154 // The other folders of wp-content (cache, languages, upgrade and
2155 // whatever else is there), which belong to no plugin and no theme and
2156 // had no turn. Before the plugins and the themes, which on a large
2157 // site can use up the whole scan, and with a share of the time of
2158 // their own, so a cache of a hundred thousand files cannot do the
2159 // same to them.
2160 $content_results = null;
2161 if ( ! empty( $options['scan_plugins'] ) && ! $this->is_time_exceeded() ) {
2162 $content_results = $this->scan_content_folders();
2163 $results['suspicious'] = array_merge( $results['suspicious'], $content_results['suspicious'] );
2164 $results['extra'] = array_merge( $results['extra'], $content_results['extra'] );
2165 $results['errors'] = array_merge( $results['errors'], $content_results['errors'] );
2166 } elseif ( ! empty( $options['scan_plugins'] ) ) {
2167 $not_checked['areas'][] = 'content';
2168 }
2169
2170 // Scan plugins
2171 if ( ! empty( $options['scan_plugins'] ) && ! $this->is_time_exceeded() ) {
2172 $plugin_results = $this->scan_plugins();
2173 $results = $this->merge_results( $results, $plugin_results );
2174
2175 $not_checked['plugins'] = $plugin_results['not_checked'] ?? array();
2176 $checked_folders = $plugin_results['checked'] ?? array();
2177
2178 if ( empty( $plugin_results['unlisted_checked'] ) ) {
2179 $not_checked['areas'][] = 'unlisted';
2180 }
2181 } elseif ( ! empty( $options['scan_plugins'] ) ) {
2182 $not_checked['areas'][] = 'plugins';
2183 $not_checked['areas'][] = 'unlisted';
2184 }
2185
2186 // Scan themes
2187 if ( ! empty( $options['scan_themes'] ) && ! $this->is_time_exceeded() ) {
2188 $theme_results = $this->scan_themes();
2189 $results = $this->merge_results( $results, $theme_results );
2190
2191 $not_checked['themes'] = $theme_results['not_checked'] ?? array();
2192 $checked_folders = array_merge( $checked_folders, $theme_results['checked'] ?? array() );
2193 } elseif ( ! empty( $options['scan_themes'] ) ) {
2194 $not_checked['areas'][] = 'themes';
2195 }
2196
2197 // What could only be searched for hidden code: plugins and themes
2198 // first, as they are found, and the folders of wp-content after them.
2199 if ( null !== $content_results ) {
2200 $results['unverified'] = array_values( array_unique( array_merge( $results['unverified'] ?? array(), $content_results['unverified'] ) ) );
2201 }
2202
2203 // Mark as incomplete if time was exceeded
2204 if ( $this->is_time_exceeded() ) {
2205 $results['incomplete'] = true;
2206 $results['errors'][] = __( 'Scan was incomplete due to time limit. Results may be partial.', 'vigilante' );
2207 }
2208
2209 // Stored after the last merge: merge_results() keeps only the keys it
2210 // knows.
2211 $not_checked['areas'] = array_values( array_unique( $not_checked['areas'] ) );
2212 $results['not_checked'] = $not_checked;
2213
2214 // Only with plugins or themes left out: a scan that reached them all
2215 // has nothing to make room for.
2216 if ( ! empty( $not_checked['plugins'] ) || ! empty( $not_checked['themes'] ) || array_intersect( array( 'plugins', 'themes' ), $not_checked['areas'] ) ) {
2217 $results['checked_folders'] = array_values( array_unique( $checked_folders ) );
2218 }
2219
2220 // Filter out ignored files from all result categories
2221 $results['modified'] = $this->filter_ignored( $results['modified'] );
2222 $results['suspicious'] = $this->filter_ignored( $results['suspicious'] );
2223 $results['extra'] = $this->filter_ignored( $results['extra'] );
2224
2225 $results['scan_time'] = round( microtime( true ) - $this->scan_start_time, 2 );
2226
2227 // Log the scan only if activity_log is available
2228 if ( $this->activity_log ) {
2229 $has_issues = ! empty( $results['modified'] ) || ! empty( $results['suspicious'] ) || ! empty( $results['extra'] );
2230 $severity = $has_issues ? 'warning' : 'info';
2231
2232 /* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */
2233 $log_text = __( 'File integrity scan completed: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' );
2234
2235 if ( $results['incomplete'] ) {
2236 /* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */
2237 $log_text = __( 'File integrity scan ran out of time, so its result is partial: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' );
2238 }
2239
2240 $this->activity_log->log(
2241 'file',
2242 'integrity_scan',
2243 sprintf(
2244 $log_text,
2245 $results['scanned'],
2246 count( $results['modified'] ),
2247 count( $results['suspicious'] ),
2248 count( $results['extra'] )
2249 ),
2250 array(
2251 'scanned' => $results['scanned'],
2252 'modified' => count( $results['modified'] ),
2253 'suspicious' => count( $results['suspicious'] ),
2254 'extra' => count( $results['extra'] ),
2255 'scan_time' => $results['scan_time'],
2256 'incomplete' => $results['incomplete'],
2257 ),
2258 $severity
2259 );
2260 }
2261
2262 // Closed plugins check: queries the wp.org repository for the closure status
2263 // of every installed plugin slug. Independent of the file-level scan_* toggles
2264 // (gated by its own `check_closed_plugins` toggle in Scan Scope). Runs BEFORE
2265 // the notification call so closed plugins are folded into the scan email
2266 // (instead of triggering a separate one-shot). Quick (~10 s for 50 plugins).
2267 //
2268 // suppress_email=true: this entry point is the file integrity scan; the
2269 // daily plugin-status cron passes suppress_email=false so urgent closures
2270 // still produce an immediate alert when the file scan is on a weekly schedule.
2271 if ( ! empty( $options['check_closed_plugins'] ) ) {
2272 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
2273 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
2274 }
2275 $closed_checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
2276 $closed_checker->check_all_plugins( true, true );
2277 }
2278
2279 // Send email notification based on notify_level (now also includes closed
2280 // plugins picked up just above).
2281 $this->maybe_send_notification( $results );
2282
2283 return $results;
2284 }
2285
2286 /**
2287 * Scan WordPress core files
2288 *
2289 * @return array Scan results.
2290 */
2291 private function scan_core_files() {
2292 $results = array(
2293 'scanned' => 0,
2294 'ok' => 0,
2295 'modified' => array(),
2296 'missing' => array(),
2297 'errors' => array(),
2298 );
2299
2300 // Get official checksums from WordPress.org
2301 $this->core_checksums_are_english = false;
2302 $checksums = $this->get_core_checksums();
2303
2304 if ( is_wp_error( $checksums ) ) {
2305 $results['errors'][] = $checksums->get_error_message();
2306 return $results;
2307 }
2308
2309 // The plugins and themes that get a turn of their own in this scan.
2310 $own_turn = array(
2311 'plugins' => array(),
2312 'themes' => array(),
2313 );
2314
2315 if ( ! empty( $this->options['scan_plugins'] ) ) {
2316 if ( ! function_exists( 'get_plugins' ) ) {
2317 require_once ABSPATH . 'wp-admin/includes/plugin.php';
2318 }
2319
2320 foreach ( array_keys( get_plugins() ) as $plugin_file ) {
2321 $slash = strpos( $plugin_file, '/' );
2322
2323 if ( false !== $slash ) {
2324 $own_turn['plugins'][ substr( $plugin_file, 0, $slash ) ] = true;
2325 }
2326 }
2327 }
2328
2329 if ( ! empty( $this->options['scan_themes'] ) ) {
2330 foreach ( array_keys( wp_get_themes() ) as $stylesheet ) {
2331 $stylesheet = (string) $stylesheet;
2332 $slash = strpos( $stylesheet, '/' );
2333
2334 $own_turn['themes'][ false === $slash ? $stylesheet : substr( $stylesheet, 0, $slash ) ] = true;
2335 }
2336 }
2337
2338 foreach ( $checksums as $file => $expected_hash ) {
2339 // Check time limit
2340 if ( $this->is_time_exceeded() ) {
2341 break;
2342 }
2343
2344 $file_path = ABSPATH . $file;
2345
2346 // Skip excluded paths
2347 if ( $this->is_path_excluded( $file_path ) ) {
2348 continue;
2349 }
2350
2351 // Skip excluded extensions
2352 if ( $this->is_extension_excluded( $file_path ) ) {
2353 continue;
2354 }
2355
2356 // Skip known false positives (e.g. readme.html, license.txt)
2357 if ( in_array( $file, $this->core_known_false_positives, true ) ) {
2358 continue;
2359 }
2360
2361 // Skip translations that travel inside the localized core ZIP but do
2362 // not belong to core. WordPress.org's localized checksum manifest
2363 // lists Akismet and the default themes' language files (8 entries on
2364 // every non-en_US locale, none on en_US), yet they are updated on the
2365 // plugin and theme cycle and are absent from the core language pack.
2366 // Deleting an unused plugin or theme, which this plugin's own audit
2367 // recommends, otherwise left permanent "missing core file" findings.
2368 if ( 0 === strpos( $file, 'wp-content/languages/plugins/' )
2369 || 0 === strpos( $file, 'wp-content/languages/themes/' ) ) {
2370 continue;
2371 }
2372
2373 // The bundled themes and plugins have their own turn. Akismet and
2374 // the default themes are in this list at the version WordPress
2375 // shipped, and they update on their own cycle: every one of those
2376 // updates showed as a modified core file, and deleting one that is
2377 // not in use as missing core files. The plugin and theme scans
2378 // compare them with their own published copy, so they are left to
2379 // those whenever those are on and have them in their list.
2380 if ( preg_match( '#^wp-content/(plugins|themes)/([^/]+)/#', $file, $bundled ) ) {
2381 // Deleted on purpose: nothing to compare and nothing missing.
2382 if ( ! is_dir( ABSPATH . 'wp-content/' . $bundled[1] . '/' . $bundled[2] ) ) {
2383 continue;
2384 }
2385
2386 // Its own scan compares it, but only what WordPress lists as a
2387 // plugin or as a theme gets that turn. A folder that is there
2388 // and is not listed (a plugin without its header, a theme
2389 // without its style.css) stays with the core.
2390 if ( isset( $own_turn[ $bundled[1] ][ $bundled[2] ] ) ) {
2391 continue;
2392 }
2393 }
2394
2395 $results['scanned']++;
2396
2397 if ( ! file_exists( $file_path ) ) {
2398 $results['missing'][] = array(
2399 'file' => $file,
2400 'type' => 'core',
2401 );
2402 continue;
2403 }
2404
2405 $actual_hash = md5_file( $file_path );
2406
2407 if ( $actual_hash !== $expected_hash && ! $this->matches_core_of_other_locale( $file, $actual_hash ) ) {
2408 // The two files a translated package changes cannot be told
2409 // from a modified one while only the English list exists. Said
2410 // as what it is, a file that could not be verified, instead of
2411 // as a modified core file.
2412 if ( $this->core_checksums_are_english && in_array( $file, array( 'wp-includes/version.php', 'wp-config-sample.php' ), true ) ) {
2413 $results['errors'][] = sprintf(
2414 /* translators: %s: File name */
2415 __( '%s differs from the English package of WordPress, and WordPress.org has not published the checksums for the language of this site yet, so it could not be verified.', 'vigilante' ),
2416 $file
2417 );
2418 continue;
2419 }
2420
2421 $results['modified'][] = array(
2422 'file' => $file,
2423 'type' => 'core',
2424 'expected_hash' => $expected_hash,
2425 'actual_hash' => $actual_hash,
2426 );
2427 } else {
2428 $results['ok']++;
2429 }
2430 }
2431
2432 $results['modified'] = $this->drop_stale_language_mismatches( $results['modified'], $results );
2433
2434 return $results;
2435 }
2436
2437 /**
2438 * Drop language files that only mismatch because the manifest was stale
2439 *
2440 * wp.org rebuilds the checksum manifest every time GlotPress rebuilds a
2441 * language pack, and that happens without the WordPress version moving. The
2442 * cache key here is version plus locale, so it does not expire when that
2443 * happens, and the site spends up to a day comparing today's translation
2444 * files against yesterday's manifest. That is where the bursts of "modified
2445 * core files" under wp-content/languages/ come from, and they are not
2446 * modifications at all.
2447 *
2448 * So before reporting one, the manifest is fetched again bypassing the
2449 * cache, once per scan, and whatever matches the fresh copy is dropped.
2450 * Anything still mismatching is reported as before.
2451 *
2452 * @since 2.9.9
2453 *
2454 * @param array $modified Entries flagged as modified.
2455 * @param array $results Scan results, to move the recovered files to 'ok'.
2456 * @return array Entries that are still modified.
2457 */
2458 private function drop_stale_language_mismatches( $modified, &$results ) {
2459 if ( empty( $modified ) ) {
2460 return $modified;
2461 }
2462
2463 $suspects = array();
2464 foreach ( $modified as $entry ) {
2465 if ( 0 === strpos( $entry['file'], 'wp-content/languages/' ) ) {
2466 $suspects[ $entry['file'] ] = true;
2467 }
2468 }
2469
2470 if ( empty( $suspects ) ) {
2471 return $modified;
2472 }
2473
2474 $fresh = $this->get_core_checksums( true );
2475
2476 if ( is_wp_error( $fresh ) || empty( $fresh ) ) {
2477 return $modified;
2478 }
2479
2480 $kept = array();
2481
2482 foreach ( $modified as $entry ) {
2483 $file = $entry['file'];
2484
2485 if ( ! isset( $suspects[ $file ] ) || ! isset( $fresh[ $file ] ) ) {
2486 $kept[] = $entry;
2487 continue;
2488 }
2489
2490 if ( $this->hash_matches_published( ABSPATH . $file, $fresh[ $file ] ) ) {
2491 $results['ok']++;
2492 continue;
2493 }
2494
2495 $kept[] = $entry;
2496 }
2497
2498 return $kept;
2499 }
2500
2501 /**
2502 * Scan WordPress root directory for non-core files
2503 *
2504 * Compares files in ABSPATH (non-recursive) against the official core
2505 * checksums list. PHP files not in the core distribution are flagged as
2506 * suspicious (common attack vector: info.php, shell.php, backdoors).
2507 * Non-PHP files not in the known safe list are flagged as extra/additional.
2508 * Dotfiles and known safe files (robots.txt, etc.) are skipped.
2509 *
2510 * @return array Array with 'suspicious' and 'extra' sub-arrays.
2511 */
2512 private function scan_root_files() {
2513 $found = array(
2514 'suspicious' => array(),
2515 'extra' => array(),
2516 );
2517
2518 // Get core checksums to know which root files are legitimate
2519 $checksums = $this->get_core_checksums();
2520 if ( is_wp_error( $checksums ) ) {
2521 return $found;
2522 }
2523
2524 // Build list of known core root files from checksums (only root-level, no directory prefix)
2525 $core_root_files = array();
2526 foreach ( array_keys( $checksums ) as $file ) {
2527 // Only root-level files (no directory separator)
2528 if ( false === strpos( $file, '/' ) ) {
2529 $core_root_files[] = $file;
2530 }
2531 }
2532
2533 // Also add wp-config.php which is not in checksums but is core
2534 $core_root_files[] = 'wp-config.php';
2535
2536 $php_extensions = self::php_extensions();
2537
2538 // Scan only direct children of ABSPATH (not recursive)
2539 $root_path = untrailingslashit( ABSPATH );
2540 $handle = opendir( $root_path );
2541
2542 if ( ! $handle ) {
2543 return $found;
2544 }
2545
2546 while ( false !== ( $entry = readdir( $handle ) ) ) {
2547 if ( $this->is_time_exceeded() ) {
2548 break;
2549 }
2550
2551 // Skip . and ..
2552 if ( '.' === $entry || '..' === $entry ) {
2553 continue;
2554 }
2555
2556 // PHP reads .user.ini and php.ini from the folder of the script, and
2557 // either can make it load a file before every request: the same
2558 // door as .htaccess, for servers that run PHP outside Apache.
2559 if ( in_array( strtolower( $entry ), array( '.user.ini', 'php.ini' ), true ) ) {
2560 $config = $this->is_path_excluded( $root_path . '/' . $entry ) ? null : self::server_config_finding( $root_path . '/' . $entry, $entry );
2561
2562 if ( $config ) {
2563 $config['item']['type'] = 'php_config_in_root';
2564 $found['extra'][] = $config['item'];
2565 }
2566 continue;
2567 }
2568
2569 // Skip dotfiles (.htaccess, .env, etc.): handled by firewall protection
2570 if ( 0 === strpos( $entry, '.' ) ) {
2571 continue;
2572 }
2573
2574 $full_path = $root_path . '/' . $entry;
2575
2576 // Skip directories — we only care about files in root
2577 if ( is_dir( $full_path ) ) {
2578 continue;
2579 }
2580
2581 // Skip if this is a known core file
2582 if ( in_array( $entry, $core_root_files, true ) ) {
2583 continue;
2584 }
2585
2586 // Skip excluded paths
2587 if ( $this->is_path_excluded( $full_path ) ) {
2588 continue;
2589 }
2590
2591 // Skip known safe non-PHP root files
2592 if ( in_array( strtolower( $entry ), $this->known_safe_root_files, true ) ) {
2593 continue;
2594 }
2595
2596 // Site verification files and sitemaps that are what their name says.
2597 if ( self::is_harmless_root_file( $entry, $full_path ) ) {
2598 continue;
2599 }
2600
2601 $extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) );
2602
2603 if ( in_array( $extension, $php_extensions, true ) ) {
2604 // Check if this is a legacy WordPress core file (removed from newer versions)
2605 if ( in_array( $entry, $this->legacy_core_root_files, true ) ) {
2606 $found['extra'][] = array(
2607 'file' => $entry,
2608 'type' => 'legacy_core',
2609 'reason' => __( 'Legacy WordPress core file, removed in newer versions. Safe to delete.', 'vigilante' ),
2610 );
2611 continue;
2612 }
2613
2614 // Silence-is-golden placeholders dropped here by some setups
2615 // (e.g. WordPress installed in a subdirectory, or third-party tooling).
2616 if ( $this->is_silence_golden_file( $full_path ) ) {
2617 continue;
2618 }
2619
2620 // PHP file not in core = suspicious
2621 $reason = __( 'Non-core PHP file in WordPress root directory', 'vigilante' );
2622
2623 // Scan content for specific patterns
2624 $pattern = $this->search_file( $full_path );
2625 if ( $pattern ) {
2626 /* translators: %s: Suspicious pattern found */
2627 $reason = sprintf( __( 'Non-core PHP in root with suspicious code: %s', 'vigilante' ), $pattern );
2628 }
2629
2630 $found['suspicious'][] = array(
2631 'file' => $entry,
2632 'type' => 'php_in_root',
2633 'reason' => $reason,
2634 );
2635 } else {
2636 // A copy of the configuration, a database dump or an archive
2637 // is not one more loose file: whoever guesses its name can
2638 // download it.
2639 if ( false !== stripos( $entry, 'wp-config' ) || preg_match( '/\.(?:sql|bak|old|orig|save|swp|dump|zip|gz|tgz|tar|7z|rar)$/i', $entry ) ) {
2640 $found['extra'][] = array(
2641 'file' => $entry,
2642 'type' => 'backup_in_root',
2643 'reason' => __( 'Backup or copy of a sensitive file in the site root, where it can be downloaded', 'vigilante' ),
2644 );
2645 continue;
2646 }
2647
2648 // Non-PHP, non-known-safe file = additional (informational)
2649 $found['extra'][] = array(
2650 'file' => $entry,
2651 'type' => 'extra_root',
2652 'reason' => __( 'Non-core file in WordPress root directory', 'vigilante' ),
2653 );
2654 }
2655 }
2656
2657 closedir( $handle );
2658
2659 return $found;
2660 }
2661
2662 /**
2663 * The line of a .user.ini or php.ini that makes PHP load a file by itself
2664 *
2665 * @param string $path Absolute path of the file.
2666 * @return string|null The line, an empty string when there is none, or
2667 * null when the file is too large to be read, which
2668 * the caller reports instead of taking for clean.
2669 */
2670 private static function php_ini_loads( $path ) {
2671 // PHP runs as the same user: what it cannot read here it cannot apply.
2672 if ( ! is_readable( $path ) ) {
2673 return '';
2674 }
2675
2676 if ( filesize( $path ) > MB_IN_BYTES ) {
2677 return null;
2678 }
2679
2680 $content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to look at its directives; nothing is written.
2681
2682 foreach ( preg_split( '/\r\n|\r|\n/', (string) $content ) as $line ) {
2683 $line = trim( $line );
2684
2685 if ( '' === $line || ';' === $line[0] || '#' === $line[0] ) {
2686 continue;
2687 }
2688
2689 if ( preg_match( '/^(auto_prepend_file|auto_append_file)\s*=\s*(.*)$/i', $line, $parts ) ) {
2690 $value = trim( $parts[2], " \t\"'" );
2691
2692 if ( '' !== $value && 'none' !== strtolower( $value ) ) {
2693 return strtolower( $parts[1] ) . ' = ' . substr( $value, 0, 200 );
2694 }
2695 }
2696 }
2697
2698 return '';
2699 }
2700
2701 /**
2702 * What a .htaccess, .user.ini or php.ini outside uploads makes the server do
2703 *
2704 * Inside a plugin, a theme, wp-content or a WordPress folder, one of these
2705 * files is a finding only when it makes the server run code or load a file.
2706 * One that denies access, or does nothing of the kind, is left alone.
2707 *
2708 * @param string $file_path Absolute path.
2709 * @param string $relative Path for display.
2710 * @return array|null Array with 'category' and 'item', or null.
2711 */
2712 private static function server_config_finding( $file_path, $relative ) {
2713 $item = array(
2714 'file' => $relative,
2715 'type' => 'server_config',
2716 );
2717
2718 if ( '.htaccess' === strtolower( basename( $file_path ) ) ) {
2719 if ( ! is_readable( $file_path ) ) {
2720 return null;
2721 }
2722
2723 if ( filesize( $file_path ) >= 65536 ) {
2724 $item['reason'] = __( '.htaccess file that is too large to be read', 'vigilante' );
2725
2726 return array(
2727 'category' => 'extra',
2728 'item' => $item,
2729 );
2730 }
2731
2732 $facts = self::read_htaccess_rules( file_get_contents( $file_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to look at its directives; nothing is written.
2733
2734 if ( empty( $facts['runs'] ) ) {
2735 return null;
2736 }
2737
2738 $item['reason'] = self::htaccess_run_reason( $facts );
2739
2740 return array(
2741 'category' => 'suspicious',
2742 'item' => $item,
2743 );
2744 }
2745
2746 $loads = self::php_ini_loads( $file_path );
2747
2748 if ( '' === $loads ) {
2749 return null;
2750 }
2751
2752 $item['reason'] = null === $loads
2753 ? __( 'PHP configuration file that is too large to be read', 'vigilante' )
2754 /* translators: %s: The line of the file that does it */
2755 : sprintf( __( 'PHP configuration file that loads a file on every request: %s', 'vigilante' ), $loads );
2756
2757 return array(
2758 'category' => 'extra',
2759 'item' => $item,
2760 );
2761 }
2762
2763 /**
2764 * Whether a loose file in the site root is a verification file or a sitemap
2765 *
2766 * By what it contains or by its kind, never by its name alone: a Google
2767 * verification file is one line that repeats its own name, and a sitemap
2768 * is XML, which no server runs.
2769 *
2770 * @param string $entry File name.
2771 * @param string $full_path Absolute path.
2772 * @return bool
2773 */
2774 private static function is_harmless_root_file( $entry, $full_path ) {
2775 if ( preg_match( '/^[a-z0-9_-]*sitemap[a-z0-9_-]*\.xml(?:\.gz)?$/i', $entry ) ) {
2776 return true;
2777 }
2778
2779 if ( preg_match( '/^google[0-9a-f]{16}\.html$/', $entry ) && is_readable( $full_path ) && filesize( $full_path ) < 200 ) {
2780 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to compare it with its own name; nothing is written.
2781
2782 return 'google-site-verification: ' . $entry === trim( (string) $content );
2783 }
2784
2785 return false;
2786 }
2787
2788 /**
2789 * Walk a folder and everything under it without stopping at what cannot be read
2790 *
2791 * A folder PHP cannot open used to end the whole walk in silence, which is
2792 * all it took to blind a scan: one unreadable folder early in the order,
2793 * and nothing after it was looked at. Now the walk goes past it, and the
2794 * caller reports it (see unreadable_folder_error()).
2795 *
2796 * @param string $directory Folder to walk.
2797 * @param callable|null $skip Given the path of a folder, whether to leave
2798 * it and everything under it out of the walk.
2799 * @return RecursiveIteratorIterator Folders included: the caller skips them.
2800 */
2801 private static function walk_folder( $directory, $skip = null ) {
2802 $folder = new RecursiveDirectoryIterator( $directory, RecursiveDirectoryIterator::SKIP_DOTS );
2803
2804 if ( null !== $skip ) {
2805 $folder = new RecursiveCallbackFilterIterator(
2806 $folder,
2807 static function ( $current ) use ( $skip ) {
2808 return ! ( $current->isDir() && call_user_func( $skip, $current->getPathname() ) );
2809 }
2810 );
2811 }
2812
2813 return new RecursiveIteratorIterator(
2814 $folder,
2815 RecursiveIteratorIterator::SELF_FIRST,
2816 RecursiveIteratorIterator::CATCH_GET_CHILD
2817 );
2818 }
2819
2820 /**
2821 * What the scan says about a folder it could not read
2822 *
2823 * @param string $path Absolute path of the folder.
2824 * @return string
2825 */
2826 private static function unreadable_folder_error( $path ) {
2827 return sprintf(
2828 /* translators: %s: Folder path */
2829 __( 'The folder %s could not be read, so what is inside it was not checked.', 'vigilante' ),
2830 str_replace( ABSPATH, '', $path )
2831 );
2832 }
2833
2834 /**
2835 * Extensions a server may be set up to run as PHP
2836 *
2837 * One list for uploads, the site root, the core folders and the plugin and
2838 * theme folders. Each had its own until now, and none had them all.
2839 *
2840 * @return array
2841 */
2842 private static function php_extensions() {
2843 return array( 'php', 'php2', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8', 'phtml', 'pht', 'phar', 'phps', 'inc' );
2844 }
2845
2846 /**
2847 * Look in wp-admin and wp-includes for files WordPress does not ship
2848 *
2849 * The comparison with the official checksums only visits the files the
2850 * checksums name, so a file added to either folder was never looked at. A
2851 * PHP file there that is not part of the installed version is reported as
2852 * suspicious. A file that changes how the server runs code (.htaccess,
2853 * .user.ini, php.ini) is reported as extra, or as suspicious when it
2854 * makes the server run code. Anything else that is not code, such as an
2855 * error log the host writes there, is left alone.
2856 *
2857 * @return array Array with 'suspicious', 'extra' and 'errors' sub-arrays.
2858 */
2859 private function scan_core_folders() {
2860 $found = array(
2861 'suspicious' => array(),
2862 'extra' => array(),
2863 'errors' => array(),
2864 );
2865
2866 $checksums = $this->get_core_checksums();
2867 if ( is_wp_error( $checksums ) ) {
2868 return $found;
2869 }
2870
2871 $php_extensions = self::php_extensions();
2872 $config_files = array( '.htaccess', '.user.ini', 'php.ini' );
2873
2874 foreach ( array( 'wp-admin', 'wp-includes' ) as $folder ) {
2875 $directory = ABSPATH . $folder;
2876
2877 if ( ! is_dir( $directory ) ) {
2878 continue;
2879 }
2880
2881 try {
2882 foreach ( self::walk_folder( $directory ) as $file ) {
2883 if ( $this->is_time_exceeded() ) {
2884 break 2;
2885 }
2886
2887 $file_path = $file->getPathname();
2888
2889 if ( $file->isDir() ) {
2890 if ( ! $file->isReadable() ) {
2891 $found['errors'][] = self::unreadable_folder_error( $file_path );
2892 }
2893 continue;
2894 }
2895
2896 $relative = $this->normalize_rel_path( str_replace( '\\', '/', str_replace( ABSPATH, '', $file_path ) ) );
2897
2898 if ( isset( $checksums[ $relative ] ) || $this->is_path_excluded( $file_path ) ) {
2899 continue;
2900 }
2901
2902 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
2903
2904 if ( in_array( $extension, $php_extensions, true ) ) {
2905 // The placeholder some hosts and tools drop in every folder.
2906 if ( $this->is_silence_golden_file( $file_path ) ) {
2907 continue;
2908 }
2909
2910 // Translation files, on the installations old enough
2911 // to keep them in wp-includes/languages: text and
2912 // nothing else, which is checked and not assumed.
2913 if ( self::is_inside_languages( $file_path ) && true === $this->is_literal_data( $file_path ) ) {
2914 continue;
2915 }
2916
2917 /* translators: %s: Folder name, wp-admin or wp-includes */
2918 $reason = sprintf( __( 'File in %s that is not part of the installed WordPress version', 'vigilante' ), $folder );
2919
2920 $pattern = $this->search_file( $file_path );
2921 if ( $pattern ) {
2922 /* translators: 1: Folder name, wp-admin or wp-includes, 2: Suspicious pattern found */
2923 $reason = sprintf( __( 'File in %1$s that is not part of WordPress, with suspicious code: %2$s', 'vigilante' ), $folder, $pattern );
2924 }
2925
2926 $found['suspicious'][] = array(
2927 'file' => $relative,
2928 'type' => 'php_in_core',
2929 'reason' => $reason,
2930 );
2931 } elseif ( in_array( strtolower( basename( $file_path ) ), $config_files, true ) ) {
2932 $config = self::server_config_finding( $file_path, $relative );
2933
2934 if ( $config && 'suspicious' === $config['category'] ) {
2935 $found['suspicious'][] = $config['item'];
2936 continue;
2937 }
2938
2939 $found['extra'][] = array(
2940 'file' => $relative,
2941 'type' => 'config_in_core',
2942 /* translators: %s: Folder name, wp-admin or wp-includes */
2943 'reason' => $config ? $config['item']['reason'] : sprintf( __( 'Server configuration file in %s', 'vigilante' ), $folder ),
2944 );
2945 }
2946 }
2947 } catch ( Exception $e ) {
2948 $found['errors'][] = self::unreadable_folder_error( $directory );
2949 }
2950 }
2951
2952 return $found;
2953 }
2954
2955 /**
2956 * Look for code where there is no official copy to compare with
2957 *
2958 * Must-use plugins, drop-ins, the loose PHP files of wp-content, of the
2959 * plugins folder and of the themes folder, and the folders in those two
2960 * that WordPress does not list as a plugin or as a theme, were not opened
2961 * by any part of the scan, although WordPress loads some of them on every
2962 * request and the rest can be reached by their address. They are searched
2963 * the way a plugin from outside WordPress.org is, for code written the way
2964 * malware hides itself. A loose PHP file or a folder with PHP that is
2965 * neither a plugin, a theme nor a drop-in is listed as extra, and so is a
2966 * .htaccess, .user.ini or php.ini there that makes the server run code or
2967 * load a file.
2968 *
2969 * @return array Array with 'suspicious', 'extra', 'unverified' and 'errors' sub-arrays.
2970 */
2971 private function scan_unlisted_code() {
2972 $found = array(
2973 'suspicious' => array(),
2974 'extra' => array(),
2975 'unverified' => array(),
2976 'errors' => array(),
2977 );
2978
2979 // Must-use plugins: the whole folder.
2980 if ( defined( 'WPMU_PLUGIN_DIR' ) && is_dir( WPMU_PLUGIN_DIR ) ) {
2981 $mu = $this->detect_extra_files( WPMU_PLUGIN_DIR, array(), 'plugin', __( 'Must-use plugins', 'vigilante' ), true );
2982
2983 $found['suspicious'] = array_merge( $found['suspicious'], $mu['suspicious'] );
2984 $found['extra'] = array_merge( $found['extra'], $mu['extra'] );
2985 $found['errors'] = array_merge( $found['errors'], $mu['errors'] );
2986
2987 if ( $mu['php_files'] > 0 ) {
2988 $found['unverified'][] = __( 'Must-use plugins', 'vigilante' );
2989 }
2990 }
2991
2992 if ( ! function_exists( '_get_dropins' ) || ! function_exists( 'get_plugins' ) ) {
2993 require_once ABSPATH . 'wp-admin/includes/plugin.php';
2994 }
2995
2996 $php_extensions = self::php_extensions();
2997 $config_files = array( '.htaccess', '.user.ini', 'php.ini' );
2998 $dropins = array_keys( _get_dropins() );
2999
3000 // What WordPress lists as a plugin or as a theme, by the file or the
3001 // folder it lives in.
3002 $single_plugins = array();
3003 $plugin_folders = array();
3004 foreach ( get_plugins() as $plugin_file => $plugin_data ) {
3005 if ( false === strpos( $plugin_file, '/' ) ) {
3006 $single_plugins[ $plugin_file ] = $plugin_data['Name'] ?? $plugin_file;
3007 } else {
3008 $plugin_folders[ substr( $plugin_file, 0, strpos( $plugin_file, '/' ) ) ] = true;
3009 }
3010 }
3011
3012 // The themes by where they really are, because there can be more
3013 // than one folder of themes and the same name in two of them.
3014 $theme_folders = array();
3015 foreach ( wp_get_themes() as $stylesheet => $theme ) {
3016 $stylesheet = (string) $stylesheet;
3017 $slash = strpos( $stylesheet, '/' );
3018 $real = realpath( untrailingslashit( $theme->get_theme_root() ) . '/' . ( false === $slash ? $stylesheet : substr( $stylesheet, 0, $slash ) ) );
3019
3020 if ( false !== $real ) {
3021 $theme_folders[ wp_normalize_path( $real ) ] = true;
3022 }
3023 }
3024
3025 $roots = array(
3026 array( 'content', WP_CONTENT_DIR ),
3027 array( 'plugins', WP_PLUGIN_DIR ),
3028 );
3029
3030 // Every folder themes are registered in, not only the usual one. One
3031 // that is inside a plugin belongs to that plugin, and has its turn
3032 // with it.
3033 global $wp_theme_directories;
3034
3035 $plugin_roots = array();
3036 foreach ( array( WP_PLUGIN_DIR, defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : '' ) as $plugin_root ) {
3037 $real = '' === (string) $plugin_root ? false : realpath( $plugin_root );
3038
3039 if ( false !== $real ) {
3040 $plugin_roots[ wp_normalize_path( $real ) ] = true;
3041 }
3042 }
3043
3044 $theme_roots = array();
3045 foreach ( array_merge( array( get_theme_root() ), is_array( $wp_theme_directories ) ? $wp_theme_directories : array() ) as $theme_root ) {
3046 $real = '' === (string) $theme_root ? false : realpath( $theme_root );
3047 $real = false === $real ? '' : wp_normalize_path( $real );
3048
3049 if ( '' === $real || isset( $theme_roots[ $real ] ) || self::is_inside_any( $real, $plugin_roots ) ) {
3050 continue;
3051 }
3052
3053 $theme_roots[ $real ] = true;
3054 $roots[] = array( 'themes', untrailingslashit( $theme_root ) );
3055 }
3056
3057 foreach ( $roots as $root ) {
3058 list( $kind, $directory ) = $root;
3059
3060 $entries = is_dir( $directory ) ? scandir( $directory ) : false;
3061
3062 if ( ! is_array( $entries ) ) {
3063 continue;
3064 }
3065
3066 foreach ( $entries as $entry ) {
3067 if ( '.' === $entry || '..' === $entry ) {
3068 continue;
3069 }
3070
3071 if ( $this->is_time_exceeded() ) {
3072 break 2;
3073 }
3074
3075 $path = $directory . '/' . $entry;
3076
3077 if ( $this->is_path_excluded( $path ) ) {
3078 continue;
3079 }
3080
3081 $relative = str_replace( ABSPATH, '', $path );
3082
3083 // A folder among the plugins or the themes that WordPress does
3084 // not list as one: no scan had a turn for it.
3085 if ( is_dir( $path ) ) {
3086 $real = realpath( $path );
3087 $listed = 'plugins' === $kind ? isset( $plugin_folders[ $entry ] ) : ( 'themes' === $kind ? ( false !== $real && isset( $theme_folders[ wp_normalize_path( $real ) ] ) ) : true );
3088
3089 // A link is followed like any other folder, because what
3090 // is behind it is reached by its address just the same,
3091 // unless it leads back to wp-content or above it, which
3092 // would be walking the site a second time.
3093 if ( $listed || ( is_link( $path ) && self::leads_back( $path ) ) ) {
3094 continue;
3095 }
3096
3097 $inside = $this->detect_extra_files( $path, array(), 'themes' === $kind ? 'theme' : 'plugin', $entry, true );
3098
3099 $found['suspicious'] = array_merge( $found['suspicious'], $inside['suspicious'] );
3100 $found['extra'] = array_merge( $found['extra'], $inside['extra'] );
3101 $found['errors'] = array_merge( $found['errors'], $inside['errors'] );
3102
3103 if ( $inside['php_files'] > 0 ) {
3104 $found['extra'][] = array(
3105 'file' => $relative,
3106 'type' => 'php_in_content',
3107 'reason' => 'themes' === $kind
3108 ? __( 'Folder with PHP files that WordPress does not list as a theme', 'vigilante' )
3109 : __( 'Folder with PHP files that WordPress does not list as a plugin', 'vigilante' ),
3110 );
3111 }
3112 continue;
3113 }
3114
3115 if ( in_array( strtolower( $entry ), $config_files, true ) ) {
3116 $config = self::server_config_finding( $path, $relative );
3117
3118 if ( $config ) {
3119 $found[ $config['category'] ][] = $config['item'];
3120 }
3121 continue;
3122 }
3123
3124 $extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) );
3125
3126 if ( ! in_array( $extension, $php_extensions, true ) || $this->is_silence_golden_file( $path ) ) {
3127 continue;
3128 }
3129
3130 // hello.php comes with WordPress and is compared with it.
3131 if ( 'plugins' === $kind && 'hello.php' === $entry ) {
3132 continue;
3133 }
3134
3135 $pattern = $this->search_file( $path, true );
3136
3137 $known = ( 'plugins' === $kind && isset( $single_plugins[ $entry ] ) ) || ( 'content' === $kind && in_array( $entry, $dropins, true ) );
3138
3139 if ( $known ) {
3140 $found['unverified'][] = 'plugins' === $kind ? $single_plugins[ $entry ] : __( 'Drop-ins', 'vigilante' );
3141 }
3142
3143 if ( $pattern ) {
3144 $found['suspicious'][] = array(
3145 'file' => $relative,
3146 'type' => 'suspicious_plugin',
3147 /* translators: 1: Plugin or theme name, or the folder the file is in, 2: Suspicious pattern found */
3148 'reason' => sprintf( __( 'File in %1$s with suspicious code: %2$s', 'vigilante' ), dirname( $relative ), $pattern ),
3149 );
3150 } elseif ( ! $known ) {
3151 $found['extra'][] = array(
3152 'file' => $relative,
3153 'type' => 'php_in_content',
3154 'reason' => __( 'PHP file that is not a plugin, a theme or a WordPress drop-in', 'vigilante' ),
3155 );
3156 } elseif ( null === $pattern ) {
3157 $found['extra'][] = $this->not_searched_item( $path, $relative );
3158 }
3159 }
3160 }
3161
3162 $found['unverified'] = array_values( array_unique( $found['unverified'] ) );
3163
3164 return $found;
3165 }
3166
3167 /**
3168 * Look in the folders of wp-content that belong to no plugin and no theme
3169 *
3170 * cache, languages, upgrade and whatever else is there were not opened by
3171 * any part of the scan, and a PHP file in any of them can be reached by
3172 * its address like one anywhere else. Every PHP file in them is searched
3173 * for hidden code, the way a plugin from outside WordPress.org is, and a
3174 * .htaccess, .user.ini or php.ini is reported when it makes the server run
3175 * code or load a file. A folder with PHP files that could only be
3176 * searched is named among what has no official copy.
3177 *
3178 * Translation files are the exception, because what they must hold is
3179 * known: text. WordPress loads the .l10n.php files of the site language
3180 * with include on every request
3181 * (wp-includes/l10n/class-wp-translation-file-php.php:24), so one that
3182 * holds anything else is reported whatever the code looks like, and any
3183 * other PHP file among the translations is listed. A PHP file that holds
3184 * literal data and nothing else cannot run anything, and is left alone
3185 * wherever it is (see is_literal_data()).
3186 *
3187 * Plugins, themes, must-use plugins and uploads have their own scans and
3188 * are skipped here. The folders whose name says cache go last, and the
3189 * whole walk has a share of the scan time of its own, so a cache with a
3190 * hundred thousand files costs that share and no more.
3191 *
3192 * @return array Array with 'suspicious', 'extra', 'unverified' and 'errors' sub-arrays.
3193 */
3194 private function scan_content_folders() {
3195 $found = array(
3196 'suspicious' => array(),
3197 'extra' => array(),
3198 'unverified' => array(),
3199 'errors' => array(),
3200 );
3201
3202 $entries = is_dir( WP_CONTENT_DIR ) ? scandir( WP_CONTENT_DIR ) : false;
3203
3204 if ( ! is_array( $entries ) ) {
3205 return $found;
3206 }
3207
3208 // The folders with a scan of their own, by where they really are.
3209 global $wp_theme_directories;
3210
3211 $uploads = wp_get_upload_dir();
3212 $uploads_real = empty( $uploads['basedir'] ) ? false : realpath( $uploads['basedir'] );
3213 $uploads_real = false === $uploads_real ? '' : wp_normalize_path( $uploads_real );
3214 $own = array();
3215 $roots = array_merge(
3216 array( WP_PLUGIN_DIR, defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : '', get_theme_root() ),
3217 is_array( $wp_theme_directories ) ? $wp_theme_directories : array()
3218 );
3219
3220 foreach ( $roots as $root ) {
3221 $real = '' === (string) $root ? false : realpath( $root );
3222
3223 if ( false !== $real ) {
3224 $own[ wp_normalize_path( $real ) ] = true;
3225 }
3226 }
3227
3228 if ( '' !== $uploads_real ) {
3229 $own[ $uploads_real ] = true;
3230 }
3231
3232 // On a site of a network that is not the main one, the uploads of the
3233 // site live inside the uploads of the network, which are the main
3234 // site's turn.
3235 $network_uploads = is_multisite() && ! is_main_site() ? $uploads_real : '';
3236
3237 $folders = array();
3238
3239 foreach ( $entries as $entry ) {
3240 $path = WP_CONTENT_DIR . '/' . $entry;
3241
3242 if ( '.' === $entry || '..' === $entry || ! is_dir( $path ) ) {
3243 continue;
3244 }
3245
3246 $real = realpath( $path );
3247 $real = false === $real ? '' : wp_normalize_path( $real );
3248
3249 // A folder with a scan of its own, or a link into one. A link to
3250 // anywhere else is walked like the folder it stands for: deploy
3251 // tools keep languages or a cache behind one, and what is there
3252 // is reached by its address all the same.
3253 if ( '' === $real || self::is_inside_any( $real, $own ) || ( is_link( $path ) && self::leads_back( $path ) ) ) {
3254 continue;
3255 }
3256
3257 if ( '' !== $network_uploads && 0 === strpos( $network_uploads, $real . '/' ) ) {
3258 continue;
3259 }
3260
3261 if ( $this->is_path_excluded( $path . '/.' ) ) {
3262 continue;
3263 }
3264
3265 $folders[] = $entry;
3266 }
3267
3268 usort(
3269 $folders,
3270 static function ( $a, $b ) {
3271 $a_cache = false !== stripos( $a, 'cache' );
3272 $b_cache = false !== stripos( $b, 'cache' );
3273
3274 return $a_cache === $b_cache ? strcmp( $a, $b ) : ( $a_cache ? 1 : -1 );
3275 }
3276 );
3277
3278 $php_extensions = self::php_extensions();
3279 $config_files = array( '.htaccess', '.user.ini', 'php.ini' );
3280 $deadline = microtime( true ) + $this->content_folders_time;
3281
3282 // A folder with a scan of its own nested further down, or one the
3283 // user left out, is not walked at all.
3284 $skip = function ( $folder ) use ( $own ) {
3285 $real = realpath( $folder );
3286
3287 return ( false !== $real && isset( $own[ wp_normalize_path( $real ) ] ) ) || $this->is_path_excluded( $folder . '/.' );
3288 };
3289
3290 foreach ( $folders as $entry ) {
3291 $path = WP_CONTENT_DIR . '/' . $entry;
3292 $php_files = 0;
3293
3294 try {
3295 foreach ( self::walk_folder( $path, $skip ) as $file ) {
3296 if ( $this->is_time_exceeded() ) {
3297 break 2;
3298 }
3299
3300 if ( microtime( true ) > $deadline ) {
3301 $found['errors'][] = sprintf(
3302 /* translators: %s: Folder path */
3303 __( 'The folders of wp-content outside plugins, themes and uploads were not checked completely: the time for them ran out in %s. If that folder only holds files that change all the time, such as a cache, add it to Excluded Paths.', 'vigilante' ),
3304 str_replace( ABSPATH, '', $path )
3305 );
3306 break 2;
3307 }
3308
3309 $file_path = $file->getPathname();
3310
3311 if ( $file->isDir() ) {
3312 if ( ! $file->isReadable() ) {
3313 $found['errors'][] = self::unreadable_folder_error( $file_path );
3314 }
3315 continue;
3316 }
3317
3318 $basename = strtolower( basename( $file_path ) );
3319
3320 if ( in_array( $basename, $config_files, true ) ) {
3321 $config = $this->is_path_excluded( $file_path ) ? null : self::server_config_finding( $file_path, str_replace( ABSPATH, '', $file_path ) );
3322
3323 if ( $config ) {
3324 $found[ $config['category'] ][] = $config['item'];
3325 }
3326 continue;
3327 }
3328
3329 if ( ! in_array( strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ), $php_extensions, true ) ) {
3330 continue;
3331 }
3332
3333 if ( $this->is_path_excluded( $file_path ) || $this->is_silence_golden_file( $file_path ) ) {
3334 continue;
3335 }
3336
3337 $literal = $this->is_literal_data( $file_path );
3338
3339 if ( true === $literal ) {
3340 continue;
3341 }
3342
3343 $relative = str_replace( ABSPATH, '', $file_path );
3344
3345 if ( false === $literal && '.l10n.php' === substr( $basename, -9 ) ) {
3346 // Reported whatever the search finds, like a PHP file
3347 // in uploads: WordPress loads it on every request, and
3348 // no tool writes anything but the table of texts in
3349 // it. What the search finds is said next to it.
3350 $pattern = $this->search_file( $file_path, true );
3351 $reason = __( 'Translation file that holds something other than translations', 'vigilante' );
3352
3353 if ( $pattern ) {
3354 /* translators: %s: Suspicious pattern found */
3355 $reason = sprintf( __( 'Translation file with suspicious code: %s', 'vigilante' ), $pattern );
3356 }
3357
3358 $found['suspicious'][] = array(
3359 'file' => $relative,
3360 'type' => 'translation_with_code',
3361 'reason' => $reason,
3362 );
3363 continue;
3364 }
3365
3366 $pattern = $this->search_file( $file_path, true );
3367
3368 if ( $pattern ) {
3369 $found['suspicious'][] = array(
3370 'file' => $relative,
3371 'type' => 'suspicious_content',
3372 /* translators: 1: Plugin or theme name, or the folder the file is in, 2: Suspicious pattern found */
3373 'reason' => sprintf( __( 'File in %1$s with suspicious code: %2$s', 'vigilante' ), str_replace( ABSPATH, '', $path ), $pattern ),
3374 );
3375 } elseif ( null === $pattern ) {
3376 $found['extra'][] = $this->not_searched_item( $file_path, $relative );
3377 } elseif ( self::is_inside_languages( $file_path ) ) {
3378 $found['extra'][] = array(
3379 'file' => $relative,
3380 'type' => 'php_in_content',
3381 'reason' => __( 'PHP file among the translations that is not a translation file', 'vigilante' ),
3382 );
3383 } else {
3384 $php_files++;
3385 }
3386 }
3387 } catch ( Exception $e ) {
3388 $found['errors'][] = self::unreadable_folder_error( $path );
3389 }
3390
3391 if ( $php_files > 0 ) {
3392 $found['unverified'][] = str_replace( ABSPATH, '', $path );
3393 }
3394 }
3395
3396 return $found;
3397 }
3398
3399 /**
3400 * Whether a real path is one of a set of folders or lies inside one of them
3401 *
3402 * @param string $real Real path, with forward slashes.
3403 * @param array $folders Real paths with forward slashes, as keys.
3404 * @return bool
3405 */
3406 private static function is_inside_any( $real, $folders ) {
3407 foreach ( array_keys( $folders ) as $folder ) {
3408 if ( $real === $folder || 0 === strpos( $real, $folder . '/' ) ) {
3409 return true;
3410 }
3411 }
3412
3413 return false;
3414 }
3415
3416 /**
3417 * Whether a link leads to wp-content itself or to a folder above it
3418 *
3419 * @param string $path Path of the link.
3420 * @return bool True also when where it leads cannot be worked out.
3421 */
3422 private static function leads_back( $path ) {
3423 $real = realpath( $path );
3424 $content = realpath( WP_CONTENT_DIR );
3425
3426 if ( false === $real || false === $content ) {
3427 return true;
3428 }
3429
3430 return 0 === strpos( wp_normalize_path( $content ) . '/', wp_normalize_path( $real ) . '/' );
3431 }
3432
3433 /**
3434 * Whether a path is inside the folder WordPress keeps its translations in
3435 *
3436 * @param string $path Absolute path.
3437 * @return bool
3438 */
3439 private static function is_inside_languages( $path ) {
3440 if ( ! defined( 'WP_LANG_DIR' ) ) {
3441 return false;
3442 }
3443
3444 return 0 === strpos( wp_normalize_path( $path ), untrailingslashit( wp_normalize_path( WP_LANG_DIR ) ) . '/' );
3445 }
3446
3447 /**
3448 * Whether a PHP file holds literal data and nothing else
3449 *
3450 * A file such as "<?php return [ 'a' => 'b' ];" cannot run anything. It is
3451 * how WordPress, GlotPress, WP-CLI and Loco Translate write translation
3452 * files, and how plugins keep small tables. The file is read one piece at
3453 * a time, and every piece has to be a quoted string with nothing
3454 * interpolated in it, a number, true, false, null, return, an array
3455 * bracket, a comma, "=>", a dot or a semicolon. A parenthesis is
3456 * accepted only as part of "array(" and to close it, so
3457 * nothing can be called, and with those pieces there is no way to name a
3458 * variable, a function, a class or a file.
3459 *
3460 * Anything else, a comment included, and the answer is no. Saying no to a
3461 * harmless file only means it is searched like any other; saying yes to
3462 * a file with code would hide it. So this reads less than PHP does, on
3463 * purpose. Each piece is also one PHP would cut the same way: a minus
3464 * sign only in front of a number and a dot never in front of "=", or
3465 * "-=>" and ".=>" would be read here as an arrow and by PHP as an
3466 * assignment. What it accepts is checked against PHP's own reading of
3467 * the same file, at random, in the release checks.
3468 *
3469 * @param string $file_path Absolute path.
3470 * @return bool|null True when it holds only data, false when it holds
3471 * anything else, null when it could not be read whole.
3472 */
3473 private function is_literal_data( $file_path ) {
3474 if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) {
3475 return null;
3476 }
3477
3478 // With another source encoding set (zend.multibyte), a string does
3479 // not end for PHP where it ends byte by byte: nothing is vouched for.
3480 if ( ini_get( 'zend.multibyte' ) ) {
3481 return null;
3482 }
3483
3484 $size = filesize( $file_path );
3485
3486 if ( false === $size || $size > self::MAX_SEARCHED_BYTES ) {
3487 return null;
3488 }
3489
3490 $content = file_get_contents( $file_path, false, null, 0, self::MAX_SEARCHED_BYTES + 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to see whether it holds anything but data; nothing is written.
3491
3492 if ( false === $content || strlen( $content ) > self::MAX_SEARCHED_BYTES ) {
3493 return null;
3494 }
3495
3496 if ( 1 !== preg_match( '/\A<\?php[ \t\r\n]/', $content ) ) {
3497 return false;
3498 }
3499
3500 $piece = '/\G(?:[ \t\r\n]++|\'[^\'\\\\]*+(?:\\\\.[^\'\\\\]*+)*+\'|"[^"\\\\$]*+(?:\\\\.[^"\\\\$]*+)*+"|-?+\d++(?:\.\d++)?+(?:e[+-]?+\d++)?+|=>|\.(?!=)|[\[\],;)]|(?:true|false|null|return)(?![a-z0-9_\x80-\xff])|array[ \t\r\n]*+\()/is';
3501 $length = strlen( $content );
3502 $offset = 6;
3503
3504 while ( $offset < $length ) {
3505 $match = array();
3506 $result = preg_match( $piece, $content, $match, 0, $offset );
3507
3508 if ( 1 !== $result ) {
3509 return false === $result ? null : false;
3510 }
3511
3512 $offset += strlen( $match[0] );
3513 }
3514
3515 return true;
3516 }
3517
3518 // =========================================================================
3519 // Critical config file baseline monitoring (wp-config.php, .htaccess)
3520 // =========================================================================
3521
3522 /**
3523 * Scan critical root files against stored baseline hashes
3524 *
3525 * Files like wp-config.php and .htaccess have no official WordPress.org
3526 * checksum because their content is unique per installation. We maintain
3527 * our own baseline hash and alert when the file changes outside of
3528 * Vigilante's own modifications.
3529 *
3530 * On the first scan (no baseline stored yet) the baseline is created
3531 * silently — there is nothing to compare against.
3532 *
3533 * @return array Array of modified file entries (same format as core modified).
3534 */
3535 /**
3536 * Where a critical root file actually lives
3537 *
3538 * WordPress supports wp-config.php one directory above ABSPATH, guarded by
3539 * wp-settings.php not being there: that is literally what the installed core
3540 * does in wp-load.php, and it is a common hardening layout. Until 2.11.10
3541 * this module only looked inside ABSPATH, so on those installations
3542 * wp-config.php was never added to the baseline, never compared and never
3543 * mentioned: the module reported the site clean without having opened the
3544 * one file it most needs to watch. A zero is justified, never assumed. The
3545 * plugin already resolved both locations elsewhere
3546 * (Vigilante_Database_Prefix::find_wpconfig_path()), just not here. Found by
3547 * the file-by-file review of 2.11.10.
3548 *
3549 * @since 2.11.10
3550 *
3551 * @param string $filename Name of the file, such as wp-config.php.
3552 * @return string|false Absolute path, or false when it cannot be found.
3553 */
3554 private function critical_file_path( $filename ) {
3555 $root = untrailingslashit( ABSPATH );
3556 $path = $root . '/' . $filename;
3557
3558 if ( file_exists( $path ) ) {
3559 return $path;
3560 }
3561
3562 if ( 'wp-config.php' === $filename ) {
3563 $above = dirname( $root ) . '/wp-config.php';
3564
3565 // Suppressed like the core does in wp-load.php: the directory above
3566 // the install is often outside open_basedir on shared hosting, and
3567 // without the @ every scan emits a warning that can land in front of
3568 // the JSON of an AJAX scan.
3569 // 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.
3570 if ( @file_exists( $above ) && ! @file_exists( dirname( $root ) . '/wp-settings.php' ) ) {
3571 return $above;
3572 }
3573 }
3574
3575 return false;
3576 }
3577
3578 private function scan_critical_root_files() {
3579 // Before reading anything: the scan is the only thing that reaches
3580 // every site of a network on its own, through wp-cron and front-end
3581 // traffic. Hooking the cleanup to admin_init alone left every subsite
3582 // whose dashboard nobody opens with its old copy of wp-config.php,
3583 // credentials included, for as long as nobody visited it.
3584 $this->maybe_redact_stored_baseline();
3585
3586 // And claim the blocks already on disk before anything is compared,
3587 // so the first scan after updating uses the rule that will stay.
3588 $this->maybe_claim_owned_blocks();
3589
3590 $modified = array();
3591 $baseline = $this->get_critical_files_baseline();
3592 $baseline_changed = false;
3593
3594 foreach ( $this->critical_root_files as $filename ) {
3595 $full_path = $this->critical_file_path( $filename );
3596
3597 if ( false === $full_path ) {
3598 continue;
3599 }
3600
3601 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
3602 if ( false === $content ) {
3603 continue;
3604 }
3605
3606 $normalized = $this->normalize_critical_file( $filename, $content );
3607 $current_hash = md5( $normalized );
3608
3609 if ( ! isset( $baseline[ $filename ] ) ) {
3610 // First time seeing this file — store baseline silently
3611 $baseline[ $filename ] = array(
3612 'hash' => $current_hash,
3613 'size' => strlen( $content ),
3614 'content' => $this->baseline_content( $filename, $normalized ),
3615 'updated' => time(),
3616 );
3617 $baseline_changed = true;
3618 continue;
3619 }
3620
3621 // Upgrade legacy baseline entries that lack content (pre-diff format)
3622 if ( ! isset( $baseline[ $filename ]['content'] ) && $baseline[ $filename ]['hash'] === $current_hash ) {
3623 $baseline[ $filename ]['content'] = $this->baseline_content( $filename, $normalized );
3624 $baseline_changed = true;
3625 continue;
3626 }
3627
3628 /*
3629 * The file has not changed, but the copy on record is not the copy
3630 * that would be stored today: an entry written before 2.11.2 with
3631 * the credentials in it, or one written before the redaction list
3632 * grew. Rewrite it.
3633 *
3634 * Only when the hash matches, and that condition is the whole
3635 * point: if the file HAD changed, this entry is the evidence of
3636 * the change that the administrator still has to review, and
3637 * rewriting it here would quietly destroy that evidence.
3638 */
3639 if ( $baseline[ $filename ]['hash'] === $current_hash ) {
3640 $expected = $this->baseline_content( $filename, $normalized );
3641
3642 if ( $expected !== $baseline[ $filename ]['content'] ) {
3643 $baseline[ $filename ]['content'] = $expected;
3644 $baseline_changed = true;
3645 }
3646
3647 continue;
3648 }
3649
3650 /*
3651 * Everything from here down is the changed file, and only that: the
3652 * branch above returns on every matching hash, so there is no third
3653 * case and no condition left to test. It used to be wrapped in an
3654 * `if` repeating the opposite comparison, which read as if some
3655 * other path could reach this point. It could not. Flagged by
3656 * @calzbert, and worth the two lines it costs to say so.
3657 *
3658 * Both sides go through the same redaction, or every credential
3659 * line would read as a change nobody made.
3660 */
3661 /*
3662 * Where the shared files do not belong to this site, the lines are
3663 * not computed at all. The diff is shown to whoever can open this
3664 * site's screen, the administrator of a subsite included, and it
3665 * was stored in the options of every subsite with the module on.
3666 * Found by the audit of the admin surface for 2.11.8. The change is
3667 * still reported, with both sizes, and the lines are read on the
3668 * main site, where the change is approved.
3669 */
3670 if ( ! Vigilante_Settings::owns_shared_files() ) {
3671 $diff = self::network_only_diff();
3672 } else {
3673 $baseline_content = $baseline[ $filename ]['content'] ?? '';
3674 $current_content = $this->baseline_content( $filename, $normalized );
3675 $diff = ( '' !== $baseline_content && '' !== $current_content )
3676 ? $this->compute_simple_diff( $baseline_content, $current_content )
3677 : array( 'added' => array(), 'removed' => array(), 'unavailable' => true );
3678
3679 // Say why there are no lines when today's copy could not be
3680 // made safe, which approving does not change: the generic
3681 // message talks about an old baseline. Cross review of 2.11.8.
3682 if ( '' === $current_content && 'wp-config.php' === $filename ) {
3683 $diff['redaction'] = true;
3684 }
3685 }
3686
3687 $modified[] = array(
3688 'file' => $filename,
3689 'type' => 'critical_config',
3690 'expected_hash' => $baseline[ $filename ]['hash'],
3691 'actual_hash' => $current_hash,
3692 'baseline_size' => $baseline[ $filename ]['size'],
3693 'current_size' => strlen( $content ),
3694 'diff' => $diff,
3695 );
3696 }
3697
3698 if ( $baseline_changed ) {
3699 $this->write_baseline( $baseline );
3700 }
3701
3702 return $modified;
3703 }
3704
3705 /**
3706 * Compute a simple line-based diff between two strings
3707 *
3708 * Returns added and removed lines with their original line numbers.
3709 * Order is preserved. Uses a simple "line present in set" approach
3710 * which works well for config files where most lines are unique.
3711 *
3712 * @param string $old Baseline content.
3713 * @param string $new Current content.
3714 * @return array Array with 'added' and 'removed' line entries.
3715 */
3716 private function compute_simple_diff( $old, $new ) {
3717 $old_lines = explode( "\n", $old );
3718 $new_lines = explode( "\n", $new );
3719
3720 // Use hash sets for O(1) lookup. Use array_flip for cheap existence check.
3721 $old_set = array_count_values( $old_lines );
3722 $new_set = array_count_values( $new_lines );
3723
3724 $removed = array();
3725 foreach ( $old_lines as $i => $line ) {
3726 // Line only considered removed if baseline has more occurrences than current
3727 if ( ! isset( $new_set[ $line ] ) || $new_set[ $line ] < ( $old_set[ $line ] ?? 0 ) ) {
3728 $removed[] = array(
3729 'line' => $i + 1,
3730 'content' => $line,
3731 );
3732 // Decrement to handle duplicates correctly
3733 if ( isset( $old_set[ $line ] ) ) {
3734 $old_set[ $line ]--;
3735 }
3736 }
3737 }
3738
3739 // Reset for added detection
3740 $old_set = array_count_values( $old_lines );
3741 $added = array();
3742 foreach ( $new_lines as $i => $line ) {
3743 if ( ! isset( $old_set[ $line ] ) || $old_set[ $line ] < ( $new_set[ $line ] ?? 0 ) ) {
3744 $added[] = array(
3745 'line' => $i + 1,
3746 'content' => $line,
3747 );
3748 if ( isset( $new_set[ $line ] ) ) {
3749 $new_set[ $line ]--;
3750 }
3751 }
3752 }
3753
3754 return array(
3755 'added' => $added,
3756 'removed' => $removed,
3757 'unavailable' => false,
3758 );
3759 }
3760
3761 /**
3762 * Normalize critical file content by removing Vigilante-managed blocks
3763 *
3764 * This ensures that changes made by Vigilante itself (security constants,
3765 * htaccess rules) do not trigger false-positive modification alerts.
3766 * Line endings are normalized to LF to prevent false positives from
3767 * editors that change CRLF/LF.
3768 *
3769 * @param string $filename File name (e.g. 'wp-config.php').
3770 * @param string $content Raw file content.
3771 * @return string Normalized content for hashing.
3772 */
3773 private function normalize_critical_file( $filename, $content, $drop_all_original = false ) {
3774 // Normalize line endings first (CRLF and CR to LF)
3775 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
3776
3777 /*
3778 * Vigilant's own blocks are left out of the hash, so rewriting them is
3779 * not reported as somebody else's change. Until 2.11.5 that covered
3780 * everything between the markers, and every line carrying the
3781 * [VIGILANTE_ORIGINAL] marker, whatever they contained. From 2.11.5 a
3782 * block is left out only if it is exactly a block Vigilant wrote (see
3783 * remember_owned_block()), and a marked line only while uncommenting it
3784 * would still give a harmless define() (see is_vigilant_original_line()).
3785 *
3786 * Until the blocks already on disk have been claimed, the old rule
3787 * applies unchanged. That is what keeps an update from changing the
3788 * hash of a file nobody touched.
3789 */
3790 $claimed = $this->owned_blocks_claimed();
3791
3792 if ( 'wp-config.php' === $filename ) {
3793 // Vigilante constants blocks (current and legacy)
3794 foreach ( $this->wpconfig_markers as $markers ) {
3795 $content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed );
3796 }
3797
3798 // Lines commented out by Vigilante (original constants)
3799 $content = preg_replace_callback(
3800 '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m',
3801 function ( $line ) use ( $claimed, $drop_all_original ) {
3802 // $drop_all_original reproduce la regla anterior a la 2.11.5 (quitar
3803 // toda linea marcada) sobre los bloques de la regla nueva. Solo lo usa
3804 // el re-base de la transicion, para decidir si la unica diferencia con
3805 // el registro aprobado son estas lineas. Ver rebase_original_line_shift().
3806 return ( ! $claimed || $drop_all_original || $this->is_vigilant_original_line( $line[0] ) ) ? '' : $line[0];
3807 },
3808 $content
3809 );
3810 } elseif ( '.htaccess' === $filename ) {
3811 // Vigilante htaccess blocks (firewall + security headers)
3812 foreach ( $this->htaccess_markers as $markers ) {
3813 $content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed );
3814 }
3815 }
3816
3817 // Collapse multiple blank lines into one (blocks removal leaves gaps)
3818 $content = preg_replace( '/\n{3,}/', "\n\n", $content );
3819
3820 return trim( $content );
3821 }
3822
3823 /**
3824 * Leave Vigilant's blocks for one pair of markers out of the content
3825 *
3826 * Before the claim, every block, as it always was. After it, only the blocks
3827 * whose fingerprint was recorded when Vigilant wrote them. A block that does
3828 * not match, edited or planted, stays in the content: it counts in the hash
3829 * and shows up in the diff.
3830 *
3831 * The match runs from marker to marker and the removal also takes the
3832 * whitespace after the block, exactly as before, so a file whose blocks are
3833 * all Vigilant's normalizes to the same text under both rules.
3834 *
3835 * @since 2.11.5
3836 *
3837 * @param string $filename 'wp-config.php' or '.htaccess'.
3838 * @param string $content Content with normalized line endings.
3839 * @param array $markers Start and end marker.
3840 * @param bool $claimed Whether the claim has run.
3841 * @return string
3842 */
3843 private function strip_vigilant_blocks( $filename, $content, $markers, $claimed ) {
3844 $pattern = '/(' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . ')\s*/s';
3845
3846 if ( ! $claimed ) {
3847 return preg_replace( $pattern, '', $content );
3848 }
3849
3850 return preg_replace_callback(
3851 $pattern,
3852 function ( $match ) use ( $filename ) {
3853 return self::is_owned_block( $filename, $match[1] ) ? '' : $match[0];
3854 },
3855 $content
3856 );
3857 }
3858
3859 /**
3860 * Whether a line carrying the original-constant marker is one Vigilant wrote
3861 *
3862 * comment_existing_constants() puts the marker in front of a define() of a
3863 * constant it manages, and uncomment_original_constants() takes it away
3864 * again whenever the constants are applied or removed, so whatever follows
3865 * the marker gets to run some day. The line is left out of the hash only
3866 * when there is nothing but indentation before the marker, nothing after it
3867 * but a harmless define() and at most a line comment, and no PHP tag
3868 * anywhere on it. That keeps it a comment today and harmless once
3869 * uncommented. Anything else counts, and shows up in the diff.
3870 *
3871 * @since 2.11.5
3872 *
3873 * @param string $line One line of wp-config.php.
3874 * @return bool
3875 */
3876 /**
3877 * Whether every marked line of a file can run nothing at all
3878 *
3879 * The question the re-base has to answer before adopting a file is whether
3880 * the lines that carry the marker are only comments. Asking a stricter one
3881 * was wrong in both directions: the first version of the guard used
3882 * is_vigilant_original_line(), which also requires the commented define to
3883 * match a known harmless shape, so it refused to re-base a perfectly inert
3884 * line carrying an unusual define, which is exactly the case the re-base
3885 * exists for, leaving the function unable to act at all. Found by the cross
3886 * review of 2.11.10.
3887 *
3888 * The second version read one line at a time and reasoned that the marker
3889 * begins with //, so a line with nothing but whitespace before it is wholly
3890 * a comment. That is true only where PHP is already reading code, and the
3891 * second cross review of 2.11.10 built three files where it is not, all of
3892 * them valid PHP, all of them passing that test and all of them running or
3893 * printing something:
3894 *
3895 * - the marked line placed BEFORE the opening <?php, so it is inline HTML
3896 * that the server prints verbatim to the browser;
3897 * - the same after a ?> that the file already had;
3898 * - the marked line ending a block comment opened on an earlier line and
3899 * opening another one at its end, with a statement in between, which
3900 * runs like any other statement.
3901 *
3902 * So the file is read the way PHP reads it, not the way the line looks. A
3903 * marked line is inert when every token touching it is a comment or
3904 * whitespace, which answers the three at once: inline HTML is not a comment,
3905 * and neither is a statement. The shape the guard was written for, code
3906 * BEFORE the marker, is the same question from the other side.
3907 *
3908 * The three shapes are in the harness as cells X2, X3 and X4 of
3909 * matriz-escondite-marcadores.sh, written out in full there. They are not
3910 * written out here on purpose: a literal payload in a shipped file is
3911 * signature surface for the scanners this plugin is read by, and a comment
3912 * is a bad place to pay for it.
3913 *
3914 * @since 2.11.10
3915 *
3916 * @param string $content Whole file content.
3917 * @return bool True when no marked line can run or print anything.
3918 */
3919 /**
3920 * The lines of a file that carry the original-value marker
3921 *
3922 * @since 2.11.10
3923 *
3924 * @param string $content Whole file content, newlines already normalised.
3925 * @return string[]
3926 */
3927 private function marked_lines_of( $content ) {
3928 $out = array();
3929
3930 foreach ( explode( "\n", $content ) as $text ) {
3931 if ( false !== strpos( $text, $this->wpconfig_original_marker ) ) {
3932 $out[] = $text;
3933 }
3934 }
3935
3936 return $out;
3937 }
3938
3939 /**
3940 * Whether what a marked line carries would still be harmless uncommented
3941 *
3942 * Only the part after the marker matters: what comes before it is answered by
3943 * the token pass, which refuses anything that is not comment or whitespace.
3944 * Here the question is what comes BACK when uncomment_original_constants()
3945 * removes the marker, so the body has to be a single define() and nothing
3946 * else, with at most a trailing line comment. Deliberately says nothing about
3947 * WHICH constant it is: asking that was the first version of this guard, and
3948 * it refused every define it did not recognise, which is exactly the case the
3949 * re-base exists for.
3950 *
3951 * @since 2.11.10
3952 *
3953 * @param string $line One line carrying the marker.
3954 * @return bool
3955 */
3956 private function marked_line_body_is_harmless( $line ) {
3957 $at = strpos( $line, $this->wpconfig_original_marker );
3958
3959 if ( false === $at ) {
3960 return true;
3961 }
3962
3963 $body = trim( substr( $line, $at + strlen( $this->wpconfig_original_marker ) ) );
3964
3965 if ( '' === $body ) {
3966 return true;
3967 }
3968
3969 // Tokenised as PHP so the trailing comment, the strings and the nesting
3970 // are read the way PHP reads them and not with a regular expression.
3971 $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.
3972
3973 if ( empty( $tokens ) ) {
3974 return false;
3975 }
3976
3977 $statements = 0;
3978 $depth = 0;
3979
3980 foreach ( $tokens as $token ) {
3981 $type = is_array( $token ) ? $token[0] : $token;
3982
3983 if ( in_array( $type, array( T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
3984 continue;
3985 }
3986
3987 if ( '(' === $type ) {
3988 $depth++;
3989 continue;
3990 }
3991
3992 if ( ')' === $type ) {
3993 $depth--;
3994 continue;
3995 }
3996
3997 // A semicolon at the top level closes a statement. More than one, or
3998 // anything after the first, means the line carries something else.
3999 if ( ';' === $type && 0 === $depth ) {
4000 $statements++;
4001 continue;
4002 }
4003
4004 if ( $statements > 0 ) {
4005 return false;
4006 }
4007 }
4008
4009 return ( $statements <= 1 );
4010 }
4011
4012 private function marked_lines_are_inert( $content ) {
4013 $content = str_replace( "\r\n", "\n", (string) $content );
4014 $marker = $this->wpconfig_original_marker;
4015
4016 if ( '' === $content || false === strpos( $content, $marker ) ) {
4017 return true;
4018 }
4019
4020 $marked = array();
4021
4022 foreach ( explode( "\n", $content ) as $index => $text ) {
4023 if ( false !== strpos( $text, $marker ) ) {
4024 $marked[ $index + 1 ] = true;
4025 }
4026 }
4027
4028 // Lenient on purpose (no TOKEN_PARSE): a tampered file still has to be
4029 // read, and a file that cannot be tokenised is never adopted.
4030 // 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.
4031 $tokens = @token_get_all( $content );
4032
4033 if ( empty( $tokens ) ) {
4034 return false;
4035 }
4036
4037 /*
4038 * And the other half of the question, which the first token version left
4039 * out: a marked line is a COMMENTED OUT value, and uncommenting it is what
4040 * the feature exists for, so "runs nothing today" is not enough. Anything
4041 * sharing the line after the define comes back with it. The shape is real
4042 * and needs no attacker: comment_existing_constants() takes a define and
4043 * everything on its line, so
4044 * define( 'WP_DEBUG', false ); @ini_set( 'display_errors', 0 );
4045 * is commented whole, and re-basing it would adopt as approved something
4046 * that runs the moment the value is restored. The old rule refused this
4047 * too, but along with every define whose NAME it did not recognise, which
4048 * is what left the function unable to act at all. Found by the third cross
4049 * review of 2.11.10.
4050 */
4051 foreach ( $this->marked_lines_of( $content ) as $text ) {
4052 if ( ! $this->marked_line_body_is_harmless( $text ) ) {
4053 return false;
4054 }
4055 }
4056
4057 $inocuos = array( T_COMMENT, T_DOC_COMMENT, T_WHITESPACE );
4058 $linea = 1;
4059
4060 foreach ( $tokens as $token ) {
4061 $texto = is_array( $token ) ? $token[1] : $token;
4062 $tipo = is_array( $token ) ? $token[0] : null;
4063 $saltos = substr_count( $texto, "\n" );
4064 $desde = $linea;
4065 $hasta = $linea + $saltos;
4066
4067 /*
4068 * A token whose text ends in a newline puts nothing on the line that
4069 * newline opens. Counting it would make the "<?php\n" of every file
4070 * touch line 2 and refuse the legitimate case, which is what the
4071 * first version of this did.
4072 */
4073 $ultima = ( $saltos > 0 && "\n" === substr( $texto, -1 ) ) ? $hasta - 1 : $hasta;
4074 $linea = $hasta;
4075
4076 if ( null !== $tipo && in_array( $tipo, $inocuos, true ) ) {
4077 continue;
4078 }
4079
4080 for ( $l = $desde; $l <= $ultima; $l++ ) {
4081 if ( isset( $marked[ $l ] ) ) {
4082 return false;
4083 }
4084 }
4085 }
4086
4087 return true;
4088 }
4089
4090 private function is_vigilant_original_line( $line ) {
4091 if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) {
4092 return false;
4093 }
4094
4095 return 1 === preg_match(
4096 '/^[ \t]*' . preg_quote( $this->wpconfig_original_marker, '/' ) . self::harmless_define_pattern() . '[ \t]*(?:(?:\/\/|#(?!\[)).*)?$/',
4097 $line
4098 );
4099 }
4100
4101 /**
4102 * A define() that runs nothing but itself, as a regular expression fragment
4103 *
4104 * The name is one of the constants Vigilant has managed in any version. The
4105 * value is made only of literals (true, false, null, a number, a quoted
4106 * string with nothing to interpolate) and of ABSPATH, WP_CONTENT_DIR and
4107 * __DIR__, which is what a debug log path is usually built from, joined
4108 * with dots. No call, no variable, no backtick, no include.
4109 *
4110 * @since 2.11.5
4111 *
4112 * @return string Pattern without delimiters.
4113 */
4114 private static function harmless_define_pattern() {
4115 $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'
4116 . '|WP_POST_REVISIONS|AUTOSAVE_INTERVAL|EMPTY_TRASH_DAYS|WP_MEMORY_LIMIT|WP_MAX_MEMORY_LIMIT|WP_AUTO_UPDATE_CORE|CONCATENATE_SCRIPTS';
4117
4118 $value = '(?:(?i:true|false|null)|-?\d+|\'(?:[^\'\\\\]|\\\\.)*\'|"[^"\\\\$]*"|ABSPATH|WP_CONTENT_DIR|__DIR__)';
4119
4120 return 'define\s*\(\s*[\'"](?:' . $names . ')[\'"]\s*,\s*' . $value . '(?:\s*\.\s*' . $value . ')*\s*\)\s*;';
4121 }
4122
4123 /**
4124 * Whether a wp-config.php constants block can only be one Vigilant wrote
4125 *
4126 * Every version of generate_constants() has written the start marker on its
4127 * own line, then comments, blank lines and define() calls, bare or wrapped
4128 * in if ( ! defined() ), then the end marker on its own line. A block made
4129 * only of those lines runs nothing but the defines, whatever version wrote
4130 * it and whatever settings it was written with. One line of anything else,
4131 * or a PHP tag on any line, and the block is not taken.
4132 *
4133 * @since 2.11.5
4134 *
4135 * @param string $block Block from start marker to end marker, inclusive.
4136 * @param array $markers Start and end marker.
4137 * @return bool
4138 */
4139 private static function is_harmless_constants_block( $block, $markers ) {
4140 $lines = explode( "\n", str_replace( array( "\r\n", "\r" ), "\n", (string) $block ) );
4141
4142 if ( count( $lines ) < 2
4143 || rtrim( array_shift( $lines ), " \t" ) !== $markers[0]
4144 || ltrim( array_pop( $lines ), " \t" ) !== $markers[1]
4145 ) {
4146 return false;
4147 }
4148
4149 $define = self::harmless_define_pattern();
4150 $guarded = '/^if\s*\(\s*!\s*defined\s*\(\s*[\'"][A-Z_]+[\'"]\s*\)\s*\)\s*\{\s*' . $define . '\s*\}$/';
4151
4152 foreach ( $lines as $line ) {
4153 $line = trim( $line, " \t" );
4154
4155 if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) {
4156 return false;
4157 }
4158
4159 if ( '' === $line
4160 || 0 === strpos( $line, '//' )
4161 || preg_match( '/^' . $define . '$/', $line )
4162 || preg_match( $guarded, $line )
4163 ) {
4164 continue;
4165 }
4166
4167 return false;
4168 }
4169
4170 return true;
4171 }
4172
4173 /**
4174 * Take ownership of the blocks already on disk, once
4175 *
4176 * Fingerprints are recorded when Vigilant writes a block, which leaves every
4177 * block written before 2.11.5 without one. This records the blocks that can
4178 * be recognised as Vigilant's without having seen them written:
4179 *
4180 * - A .htaccess block, when it is exactly what Vigilant would write today
4181 * with the settings it has, the timestamp and the version apart. After an
4182 * update, maybe_sync_server_files() rewrites those blocks on the next
4183 * request, so the claim waits for it. Unless it has already failed: a
4184 * block Vigilant cannot rewrite is not going to start matching, and
4185 * waiting for it would keep the old rule for good.
4186 * - A wp-config.php constants block, when every line in it is a comment, a
4187 * blank line or a harmless define(). Nothing rewrites that block on an
4188 * update and its format has changed five times, so comparing it with
4189 * today's output would report every site that has not saved those
4190 * settings since. A line that could run anything is never accepted.
4191 *
4192 * A block that is not recognised stays in the hash and is reported as a
4193 * change, so the owner gets to look at it, and the activity log says why.
4194 * Nothing in the stored baseline is rewritten.
4195 *
4196 * Only where the shared files belong, a single site or the main site of a
4197 * network, because the expected blocks come from that site's settings. Until
4198 * it has run, normalize_critical_file() keeps the old rule on every site.
4199 *
4200 * @since 2.11.5
4201 */
4202 /**
4203 * The admin_init entry point of the claim, which does ask for an administrator
4204 *
4205 * admin-ajax.php fires admin_init before it decides who is asking
4206 * (wp-admin/admin-ajax.php:45), so without this an anonymous request chose
4207 * the moment the claim runs. Unlike its two neighbours in
4208 * init_cleanup_hooks(), which only drop the plugin's own copy out of the
4209 * database, the claim writes two network options, changes for the whole
4210 * network the rule normalize_critical_file() applies, and re-bases the
4211 * approved baseline.
4212 *
4213 * The gate lives here and not inside maybe_claim_owned_blocks() because the
4214 * scan calls that one directly and the scan runs from wp-cron, with no user:
4215 * putting the capability check inside left the claim unable to complete on
4216 * any site whose dashboard nobody opens, and until it completes the older,
4217 * permissive rule is the one in force, which is the hiding place 2.11.5 was
4218 * written to close. Found by the cross review of 2.11.10.
4219 *
4220 * @since 2.11.10
4221 */
4222 public function maybe_claim_owned_blocks_on_admin() {
4223 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
4224 return;
4225 }
4226
4227 $this->maybe_claim_owned_blocks();
4228 }
4229
4230 public function maybe_claim_owned_blocks() {
4231 if ( $this->owned_blocks_claimed() || ! Vigilante_Settings::owns_shared_files() ) {
4232 return;
4233 }
4234
4235 $sync_due = get_option( 'vigilante_server_files_pending' )
4236 || VIGILANTE_VERSION !== get_option( 'vigilante_server_files_version' );
4237
4238 if ( $sync_due && ! get_option( 'vigilante_server_files_retry_after' ) ) {
4239 return;
4240 }
4241
4242 $expected = null;
4243 $unclaimed = array();
4244
4245 foreach ( $this->critical_root_files as $filename ) {
4246 $full_path = $this->critical_file_path( $filename );
4247
4248 if ( false === $full_path ) {
4249 continue;
4250 }
4251
4252 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4253
4254 if ( false === $content ) {
4255 // Unreadable right now: leave the claim open and try again later.
4256 return;
4257 }
4258
4259 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
4260 $is_config = 'wp-config.php' === $filename;
4261
4262 foreach ( ( $is_config ? $this->wpconfig_markers : $this->htaccess_markers ) as $markers ) {
4263 $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '/s';
4264
4265 if ( ! preg_match_all( $pattern, $content, $found ) ) {
4266 continue;
4267 }
4268
4269 foreach ( $found[0] as $block ) {
4270 if ( $is_config ) {
4271 $ours = self::is_harmless_constants_block( $block, $markers );
4272 } else {
4273 $expected = null === $expected ? $this->expected_htaccess_blocks() : $expected;
4274 $ours = isset( $expected[ $markers[0] ] )
4275 && self::comparable_block( $block ) === self::comparable_block( $expected[ $markers[0] ] );
4276 }
4277
4278 if ( $ours ) {
4279 self::remember_owned_block( $filename, $markers[0], $block, false );
4280 } else {
4281 $unclaimed[ $filename ] = $filename;
4282 }
4283 }
4284 }
4285
4286 // The commented-out originals are judged line by line at scan time.
4287 // Looking at them here only keeps the log entry below complete.
4288 if ( $is_config && preg_match_all( '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m', $content, $marked ) ) {
4289 foreach ( $marked[0] as $line ) {
4290 if ( ! $this->is_vigilant_original_line( $line ) ) {
4291 $unclaimed[ $filename ] = $filename;
4292 }
4293 }
4294 }
4295 }
4296
4297 update_site_option( self::OWNED_BLOCKS_CLAIM_OPTION, self::OWNED_BLOCKS_CLAIMED );
4298
4299 // With the claim in place normalize uses the new rule, so a file nobody
4300 // touched whose only difference is an original line the old rule dropped
4301 // would read as changed. Re-base those, and only those, once.
4302 $this->rebase_original_line_shift();
4303
4304 if ( $unclaimed && $this->activity_log ) {
4305 $this->activity_log->log(
4306 'file',
4307 'critical_file_unrecognized_block',
4308 sprintf(
4309 /* translators: %s: comma-separated file names, such as wp-config.php or .htaccess. */
4310 __( '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' ),
4311 implode( ', ', $unclaimed )
4312 ),
4313 array( 'files' => array_values( $unclaimed ) ),
4314 'warning'
4315 );
4316 }
4317 }
4318
4319 /**
4320 * Re-base the critical files whose only change is a newly kept original line
4321 *
4322 * Until 2.11.5 the hash left out every [VIGILANTE_ORIGINAL] line; from 2.11.5
4323 * it keeps the ones whose value is not a plain constant define, which is the
4324 * right thing for the hash but moves it on a file nobody edited: the stored
4325 * baseline was taken under the old rule, and nothing re-bases wp-config.php on
4326 * an update (maybe_sync_server_files() only rewrites the .htaccess). So the
4327 * first scan after updating would report wp-config.php as changed.
4328 *
4329 * This runs once, in the same pass that claims the blocks. For each file it
4330 * re-bases to the new hash only when the baseline still matches the file with
4331 * every original line dropped, which means the blocks are exactly the approved
4332 * ones and the sole difference is those lines, the user's own commented-out
4333 * defines. A block that was edited or planted does not match with the lines
4334 * dropped, so it is left to be reported: this closes the false positive
4335 * without adopting anything that was hidden before.
4336 *
4337 * @since 2.11.5
4338 */
4339 private function rebase_original_line_shift() {
4340 $baseline = $this->get_critical_files_baseline();
4341 $changed = false;
4342
4343 foreach ( $this->critical_root_files as $filename ) {
4344 $full_path = $this->critical_file_path( $filename );
4345
4346 if ( false === $full_path || empty( $baseline[ $filename ]['hash'] ) ) {
4347 continue;
4348 }
4349
4350 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4351
4352 if ( false === $content ) {
4353 continue;
4354 }
4355
4356 /*
4357 * Never re-base a file that carries a marked line which is not
4358 * wholly a comment. The test below only establishes that the
4359 * difference lies in lines carrying the marker, and the old rule
4360 * dropped the WHOLE line, so a line with a statement in front of the
4361 * marker satisfies it (cell X1 of matriz-escondite-marcadores.sh,
4362 * where the shape is written out): re-basing would write that line
4363 * into the approved baseline and rewrite the stored content, so the
4364 * diff would stop showing it. Adopting as approved what the previous rule
4365 * hid is the one thing an integrity scanner must never do, and the
4366 * log entry of maybe_claim_owned_blocks() already promises the
4367 * opposite ("the scan reports it as a change for you to review").
4368 * Those files are left to be reported. Found by the file-by-file
4369 * review of 2.11.10.
4370 *
4371 * What counts as "wholly a comment" is decided by reading the file
4372 * as PHP reads it, not by the shape of the line: see
4373 * marked_lines_are_inert(). A line that is not recognised is not the
4374 * same thing as a line that can run something, and the first
4375 * wording of this guard confused the two.
4376 */
4377 if ( ! $this->marked_lines_are_inert( $content ) ) {
4378 continue;
4379 }
4380
4381 $current = md5( $this->normalize_critical_file( $filename, $content ) );
4382
4383 // Already in step, or a real change to something other than the
4384 // original lines: nothing to re-base here.
4385 if ( $baseline[ $filename ]['hash'] === $current
4386 || $baseline[ $filename ]['hash'] !== md5( $this->normalize_critical_file( $filename, $content, true ) )
4387 ) {
4388 continue;
4389 }
4390
4391 $normalized = $this->normalize_critical_file( $filename, $content );
4392 $baseline[ $filename ]['hash'] = $current;
4393 $baseline[ $filename ]['size'] = strlen( $content );
4394 $baseline[ $filename ]['content'] = $this->baseline_content( $filename, $normalized );
4395 $baseline[ $filename ]['updated'] = time();
4396 $changed = true;
4397 }
4398
4399 if ( $changed ) {
4400 $this->write_baseline( $baseline );
4401 }
4402 }
4403
4404 /**
4405 * The .htaccess blocks Vigilant would write today, keyed by start marker
4406 *
4407 * @since 2.11.5
4408 *
4409 * @return array
4410 */
4411 private function expected_htaccess_blocks() {
4412 $settings = $this->settings ? $this->settings : new Vigilante_Settings();
4413
4414 $classes = array(
4415 'Vigilante_Htaccess_Protection' => 'class-htaccess-protection.php',
4416 'Vigilante_Security_Headers' => 'class-security-headers.php',
4417 );
4418
4419 foreach ( $classes as $class => $file ) {
4420 if ( ! class_exists( $class ) ) {
4421 require_once VIGILANTE_INCLUDES_DIR . $file;
4422 }
4423 }
4424
4425 $headers = new Vigilante_Security_Headers( $settings );
4426
4427 return array(
4428 Vigilante_Htaccess_Protection::MARKER_START => ( new Vigilante_Htaccess_Protection( $settings ) )->generate_rules(),
4429 Vigilante_Security_Headers::MARKER_START => Vigilante_Security_Headers::MARKER_START . "\n" . $headers->generate_rules_content() . "\n" . Vigilante_Security_Headers::MARKER_END,
4430 );
4431 }
4432
4433 /**
4434 * A .htaccess block with the parts that change on every write evened out
4435 *
4436 * Two blocks Vigilant wrote with the same settings differ only in the time
4437 * they were generated and, across an update, in the version the firewall
4438 * block names. Everything else has to be identical for the claim to take
4439 * the block.
4440 *
4441 * @since 2.11.5
4442 *
4443 * @param string $block Block from start marker to end marker, inclusive.
4444 * @return string
4445 */
4446 private static function comparable_block( $block ) {
4447 $block = str_replace( array( "\r\n", "\r" ), "\n", (string) $block );
4448 $block = preg_replace( '/^# Generated: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/m', '# Generated:', $block );
4449 $block = preg_replace( '/^# Vigilante for WordPress - Firewall v[0-9][0-9A-Za-z.\-]*$/m', '# Vigilante for WordPress - Firewall v', $block );
4450
4451 return rtrim( $block, "\n" );
4452 }
4453
4454 /**
4455 * Get stored baseline hashes for critical files
4456 *
4457 * @return array Associative array keyed by filename.
4458 */
4459 public function get_critical_files_baseline() {
4460 return $this->read_baseline();
4461 }
4462
4463 /**
4464 * Update baseline hash for a single critical file
4465 *
4466 * Called by wp-config and htaccess writers after Vigilante modifies
4467 * the file, so the next scan does not flag the change as suspicious.
4468 *
4469 * @param string $filename File name relative to ABSPATH (e.g. 'wp-config.php').
4470 * @return bool True on success.
4471 */
4472 public function update_critical_file_baseline( $filename ) {
4473 $full_path = $this->critical_file_path( $filename );
4474
4475 if ( false === $full_path ) {
4476 return false;
4477 }
4478
4479 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4480 if ( false === $content ) {
4481 return false;
4482 }
4483
4484 $normalized = $this->normalize_critical_file( $filename, $content );
4485
4486 $baseline = $this->get_critical_files_baseline();
4487
4488 /*
4489 * No guard here, and there was one for a few hours during 2.11.4 that
4490 * had to come out. It refused to rewrite the record when the stored hash
4491 * no longer matched the file, meant to stop a write of ours from
4492 * approving somebody else's pending edit. Two things were wrong with it,
4493 * both measured on 10 sep 2026 by a third cross review:
4494 *
4495 * - This is also the Approve button (Vigilante_Admin_Ajax::
4496 * ajax_approve_critical_file). A moved hash is exactly the state in
4497 * which Approve is pressed, so the guard made Approve fail every time
4498 * and the warning could never be closed.
4499 * - Its premise, "our own write cannot move the normalized hash", holds
4500 * for the block and not for the rest of what the writers do.
4501 * comment_existing_constants() turns a define() into a
4502 * [VIGILANTE_ORIGINAL] line that normalize_critical_file() leaves as
4503 * an empty line, and remove_old_rules() deletes legacy .htaccess blocks
4504 * that normalize_critical_file() does not know. Both move the hash, so
4505 * the guard would have raised a false "file modified" after Vigilant's
4506 * own work, on sites that updated.
4507 *
4508 * The real fix is to know what the hash was before WE touched the file:
4509 * the writers capture it and pass it along vigilante_critical_file_
4510 * written, and this compares against that instead of against the
4511 * record. Until then this behaves as it always has, which does mean a
4512 * write of ours can adopt a third-party edit that was pending review.
4513 * That is pre-existing, and written down in the roadmap.
4514 */
4515 $baseline[ $filename ] = array(
4516 'hash' => md5( $normalized ),
4517 'size' => strlen( $content ),
4518 'content' => $this->baseline_content( $filename, $normalized ),
4519 'updated' => time(),
4520 );
4521
4522 return $this->write_baseline( $baseline );
4523 }
4524
4525 /**
4526 * Regenerate baseline for all critical files
4527 *
4528 * Used by the admin UI button and the 1.14.0 migration.
4529 *
4530 * @return array Updated baseline data.
4531 */
4532 public function regenerate_all_baselines() {
4533 $baseline = array();
4534
4535 foreach ( $this->critical_root_files as $filename ) {
4536 $full_path = $this->critical_file_path( $filename );
4537
4538 if ( false === $full_path ) {
4539 continue;
4540 }
4541
4542 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
4543 if ( false === $content ) {
4544 continue;
4545 }
4546
4547 $normalized = $this->normalize_critical_file( $filename, $content );
4548 $baseline[ $filename ] = array(
4549 'hash' => md5( $normalized ),
4550 'size' => strlen( $content ),
4551 'content' => $this->baseline_content( $filename, $normalized ),
4552 'updated' => time(),
4553 );
4554 }
4555
4556 $this->write_baseline( $baseline );
4557
4558 return $baseline;
4559 }
4560
4561 /**
4562 * Get core checksums from WordPress.org API
4563 *
4564 * @param bool $force_refresh Skip the cached copy and ask wp.org again.
4565 * @return array|WP_Error Checksums or error.
4566 */
4567 private function get_core_checksums( $force_refresh = false, $locale = null, $english_fallback = true ) {
4568 $locale = null === $locale ? get_locale() : $locale;
4569 $version = $this->wp_version;
4570
4571 // Check cache first
4572 $cache_key = 'vigilante_core_checksums_' . md5( $version . $locale );
4573
4574 if ( $force_refresh ) {
4575 delete_transient( $cache_key );
4576 }
4577
4578 $cached = get_transient( $cache_key );
4579 if ( false !== $cached ) {
4580 return $cached;
4581 }
4582
4583 // Fetch from WordPress.org
4584 $url = sprintf(
4585 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s',
4586 $version,
4587 $locale
4588 );
4589
4590 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
4591
4592 if ( is_wp_error( $response ) ) {
4593 return $response;
4594 }
4595
4596 $body = json_decode( wp_remote_retrieve_body( $response ), true );
4597
4598 if ( empty( $body['checksums'] ) ) {
4599 /*
4600 * WordPress.org has no list for this version in this language. That
4601 * is the normal state of every site that is not in English for the
4602 * hours or days after a release, until the package of its language
4603 * is built, and of the languages that never get one. Until now the
4604 * core was then not compared at all. Those sites run the English
4605 * files, so the English list is the one to compare with. It is not
4606 * kept under this language, so the next scan asks again.
4607 */
4608 if ( 'en_US' !== $locale && $english_fallback ) {
4609 $english = $this->get_core_checksums( $force_refresh, 'en_US' );
4610
4611 if ( is_array( $english ) ) {
4612 $this->core_checksums_are_english = true;
4613 return $english;
4614 }
4615 }
4616
4617 return new WP_Error( 'no_checksums', __( 'Could not retrieve WordPress core checksums', 'vigilante' ) );
4618 }
4619
4620 $checksums = $body['checksums'];
4621
4622 // Handle nested format: checksums keyed under version string (WP 6.9+)
4623 if ( isset( $checksums[ $version ] ) && is_array( $checksums[ $version ] ) ) {
4624 $checksums = $checksums[ $version ];
4625 }
4626
4627 // Cache for 24 hours
4628 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
4629
4630 return $checksums;
4631 }
4632
4633 /**
4634 * Whether a core file is the official copy of another language
4635 *
4636 * The checksums are asked for in the language of the site, and a site does
4637 * not always run the package of its language: one installed in English and
4638 * switched to Spanish afterwards keeps the English files, so its
4639 * version.php and its wp-config-sample.php do not match the Spanish list.
4640 * That is why version.php used to be left out of the comparison altogether,
4641 * which left a file WordPress loads on every request unchecked. A file that
4642 * matches the official copy of the same version in English, or in the
4643 * language its own package declares, is an official file. The language
4644 * only chooses which official list to look at: a file that was tampered
4645 * with matches none of them.
4646 *
4647 * @param string $file Path relative to the WordPress root.
4648 * @param string $actual_hash md5 of the file on disk.
4649 * @return bool
4650 */
4651 private function matches_core_of_other_locale( $file, $actual_hash ) {
4652 $current = get_locale();
4653 $others = array( 'en_US' );
4654
4655 if ( ! empty( $GLOBALS['wp_local_package'] ) && is_string( $GLOBALS['wp_local_package'] ) ) {
4656 $others[] = $GLOBALS['wp_local_package'];
4657 }
4658
4659 foreach ( array_unique( $others ) as $locale ) {
4660 if ( $locale === $current || ! preg_match( '/^[A-Za-z0-9_]{2,24}$/', $locale ) ) {
4661 continue;
4662 }
4663
4664 // Without the English list in its place: asking about another
4665 // language must not leave the scan believing that the list of the
4666 // site itself was missing.
4667 $checksums = $this->get_core_checksums( false, $locale, false );
4668
4669 if ( is_array( $checksums ) && isset( $checksums[ $file ] ) && $checksums[ $file ] === $actual_hash ) {
4670 return true;
4671 }
4672 }
4673
4674 return false;
4675 }
4676
4677 /**
4678 * Scan plugins for modifications
4679 *
4680 * @return array Scan results.
4681 */
4682 private function scan_plugins() {
4683 $results = array(
4684 'scanned' => 0,
4685 'ok' => 0,
4686 'modified' => array(),
4687 'suspicious' => array(),
4688 'extra' => array(),
4689 'errors' => array(),
4690 );
4691
4692 // Get all installed plugins
4693 if ( ! function_exists( 'get_plugins' ) ) {
4694 require_once ABSPATH . 'wp-admin/includes/plugin.php';
4695 }
4696
4697 $plugins = get_plugins();
4698 $finished = array();
4699
4700 foreach ( $plugins as $plugin_file => $plugin_data ) {
4701 // Check time limit
4702 if ( $this->is_time_exceeded() ) {
4703 break;
4704 }
4705
4706 $plugin_slug = dirname( $plugin_file );
4707
4708 if ( ! $this->plugin_has_turn( $plugin_slug ) ) {
4709 continue;
4710 }
4711
4712 // Get checksums from WordPress.org
4713 $version = $plugin_data['Version'] ?? '';
4714 $checksums = $this->get_plugin_checksums( $plugin_slug, $version );
4715
4716 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
4717
4718 // Say which plugins could not be compared, and why. A plugin that
4719 // is not on WordPress.org is named as such; a failed request is an
4720 // error of this scan, not a plugin without an official copy.
4721 if ( is_wp_error( $checksums ) ) {
4722 $results['errors'][] = sprintf(
4723 /* translators: 1: Plugin name, 2: What went wrong */
4724 __( 'The plugin %1$s could not be compared with its copy on WordPress.org: %2$s', 'vigilante' ),
4725 $plugin_data['Name'] ?? $plugin_slug,
4726 $checksums->get_error_message()
4727 );
4728 } elseif ( ! $has_checksums ) {
4729 $results['unverified'][] = $plugin_data['Name'] ?? $plugin_slug;
4730 }
4731
4732 $plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug;
4733
4734 // Check known files against checksums (only if available)
4735 if ( $has_checksums ) {
4736 foreach ( $checksums as $file => $expected_hash ) {
4737 // Check time limit inside inner loop too
4738 if ( $this->is_time_exceeded() ) {
4739 break 2; // Break both loops
4740 }
4741
4742 $file_path = $plugin_dir . '/' . $file;
4743
4744 // Skip excluded paths
4745 if ( $this->is_path_excluded( $file_path ) ) {
4746 continue;
4747 }
4748
4749 // Skip excluded extensions
4750 if ( $this->is_extension_excluded( $file_path ) ) {
4751 continue;
4752 }
4753
4754 // Skip known false positives (e.g. readme.txt, readme.md)
4755 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
4756 continue;
4757 }
4758
4759 $results['scanned']++;
4760
4761 if ( ! file_exists( $file_path ) ) {
4762 // Reported, and not just skipped: a file that is in the
4763 // published copy and not on the server is part of what
4764 // the scan found.
4765 $results['missing'][] = array(
4766 'file' => 'plugins/' . $plugin_slug . '/' . $file,
4767 'type' => 'plugin',
4768 'plugin' => $plugin_data['Name'] ?? $plugin_slug,
4769 );
4770 continue;
4771 }
4772
4773 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
4774 $results['modified'][] = array(
4775 'file' => 'plugins/' . $plugin_slug . '/' . $file,
4776 'type' => 'plugin',
4777 'plugin' => $plugin_data['Name'],
4778 'expected_hash' => $this->expected_hash_label( $expected_hash ),
4779 'actual_hash' => md5_file( $file_path ),
4780 );
4781 } else {
4782 $results['ok']++;
4783 }
4784 }
4785 } // end if $has_checksums
4786
4787 // Detect extra/suspicious files
4788 // With checksums: finds files not in the original distribution
4789 // Without checksums: scans ALL plugin files but only flags suspicious patterns
4790 if ( ! $this->is_time_exceeded() ) {
4791 $known_files = $has_checksums ? $checksums : array();
4792 $suspicious_only = ! $has_checksums; // Without checksums, only report files with suspicious code
4793 $extra_results = $this->detect_extra_files( $plugin_dir, $known_files, 'plugin', $plugin_data['Name'], $suspicious_only );
4794 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
4795 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
4796 $results['errors'] = array_merge( $results['errors'] ?? array(), $extra_results['errors'] ?? array() );
4797 }
4798
4799 // The plugin had its whole turn. The one the time runs out on
4800 // does not get here with time left, and is named with the ones
4801 // behind it.
4802 if ( ! $this->is_time_exceeded() ) {
4803 $finished[ $plugin_file ] = true;
4804
4805 // Its folder, as a line for Excluded Paths: what a partial
4806 // scan offers so that the next one spends its time on the
4807 // rest. One that is already excluded was not looked at.
4808 if ( ! $this->is_path_excluded( $plugin_dir . '/.' ) ) {
4809 $results['checked'][] = $this->relative_path( $plugin_dir );
4810 }
4811 }
4812 }
4813
4814 // The plugins the time did not reach, by name.
4815 $results['not_checked'] = array();
4816
4817 foreach ( $plugins as $plugin_file => $plugin_data ) {
4818 if ( isset( $finished[ $plugin_file ] ) || ! $this->plugin_has_turn( dirname( $plugin_file ) ) ) {
4819 continue;
4820 }
4821
4822 $results['not_checked'][] = (string) ( $plugin_data['Name'] ?? dirname( $plugin_file ) );
4823 }
4824
4825 // Must-use plugins, drop-ins and loose PHP files, which have no
4826 // official copy and no entry in the list above.
4827 $results['unlisted_checked'] = false;
4828
4829 if ( ! $this->is_time_exceeded() ) {
4830 $unlisted = $this->scan_unlisted_code();
4831
4832 $results['extra'] = array_merge( $results['extra'] ?? array(), $unlisted['extra'] );
4833 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $unlisted['suspicious'] );
4834 $results['unverified'] = array_merge( $results['unverified'] ?? array(), $unlisted['unverified'] );
4835 $results['errors'] = array_merge( $results['errors'] ?? array(), $unlisted['errors'] );
4836
4837 $results['unlisted_checked'] = ! $this->is_time_exceeded();
4838 }
4839
4840 return $results;
4841 }
4842
4843 /**
4844 * Whether a plugin gets a turn of its own in scan_plugins()
4845 *
4846 * @param string $plugin_slug Folder of the plugin, or "." for a single file.
4847 * @return bool
4848 */
4849 private function plugin_has_turn( $plugin_slug ) {
4850 // Single-file plugins are loose PHP files: scan_unlisted_code().
4851 if ( '.' === $plugin_slug ) {
4852 return false;
4853 }
4854
4855 // With the self-check on, Vigilant itself is verified by the
4856 // sha256 triple-anchor block at the start of run_scan(): scanning
4857 // it here again would duplicate findings and the md5 fetch. With
4858 // the check off by filter, Vigilant is a regular plugin (legacy
4859 // behaviour).
4860 if ( dirname( VIGILANTE_PLUGIN_BASENAME ) === $plugin_slug && Vigilante_Self_Integrity::is_on() ) {
4861 return false;
4862 }
4863
4864 // Skip slugs in their post-update grace window: wp.org may still be
4865 // publishing the new version's checksums, so a scheduled scan here
4866 // would raise benign "modified/extra" noise. The dedicated post-update
4867 // verifier (vigilante_fi_postupdate_verify) handles these instead.
4868 return ! $this->in_post_update_grace( 'plugin', $plugin_slug );
4869 }
4870
4871 /**
4872 * Whether a file on disk matches a hash WordPress.org publishes for it.
4873 *
4874 * The wp.org checksums JSON gives, per file, an md5 (and usually a sha256)
4875 * that may be a single string OR an array of strings: a file whose content
4876 * differs across the re-tagged zips that one checksums file covers (e.g. a
4877 * release that shares its checksums file with a beta) gets every valid hash
4878 * listed as an array. The old `$actual !== $expected` comparison evaluated a
4879 * 32-char string against an array as unequal unconditionally, so those files
4880 * were always reported as "modified" even when the on-disk hash was one of
4881 * the published ones. Match against membership, and accept either md5 or
4882 * sha256, so the comparison is correct and strictly stronger than md5-only.
4883 *
4884 * Robust to both shapes: the new record array( 'md5' => ..., 'sha256' => ... )
4885 * and a legacy cached value (a bare md5 string or array), so a transient
4886 * cached by an older version still compares correctly until it expires.
4887 *
4888 * @param string $file_path Absolute path to the file on disk.
4889 * @param array|string $expected Record array, or a legacy md5 string|array.
4890 * @return bool True when the file matches a published hash.
4891 */
4892 private function hash_matches_published( $file_path, $expected ) {
4893 $md5 = $expected;
4894 $sha = null;
4895 if ( is_array( $expected ) && ( array_key_exists( 'md5', $expected ) || array_key_exists( 'sha256', $expected ) ) ) {
4896 $md5 = isset( $expected['md5'] ) ? $expected['md5'] : null;
4897 $sha = isset( $expected['sha256'] ) ? $expected['sha256'] : null;
4898 }
4899
4900 if ( null !== $md5 && in_array( md5_file( $file_path ), (array) $md5, true ) ) {
4901 return true;
4902 }
4903 if ( ! empty( $sha ) && in_array( hash_file( 'sha256', $file_path ), (array) $sha, true ) ) {
4904 return true;
4905 }
4906
4907 // Fallback: some hosts and deploy pipelines rewrite text files on disk
4908 // (prepend a UTF-8 BOM, or convert LF line endings to CRLF) without
4909 // changing a single line of code. That alters the raw bytes, so the
4910 // md5/sha256 stops matching WordPress.org even though the file is
4911 // intact, which surfaced as false "modified file" alerts. Retry the
4912 // comparison against a normalized copy (BOM stripped, CRLF/CR collapsed
4913 // to LF) for text files only, so a genuine code change is still caught.
4914 if ( is_string( $file_path ) && '' !== $file_path && $this->is_text_file( $file_path ) && is_readable( $file_path ) ) {
4915 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read for hashing, not remote.
4916 $content = file_get_contents( $file_path );
4917 if ( false !== $content ) {
4918 if ( "\xEF\xBB\xBF" === substr( $content, 0, 3 ) ) {
4919 $content = substr( $content, 3 );
4920 }
4921 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
4922 if ( null !== $md5 && in_array( md5( $content ), (array) $md5, true ) ) {
4923 return true;
4924 }
4925 if ( ! empty( $sha ) && in_array( hash( 'sha256', $content ), (array) $sha, true ) ) {
4926 return true;
4927 }
4928 }
4929 }
4930
4931 return false;
4932 }
4933
4934 /**
4935 * Human-readable expected-hash value for a modified-file result record.
4936 *
4937 * The published md5 may be a string or an array; flatten it for storage.
4938 *
4939 * @param array|string $expected Record array or legacy md5 string|array.
4940 * @return string
4941 */
4942 private function expected_hash_label( $expected ) {
4943 $md5 = ( is_array( $expected ) && array_key_exists( 'md5', $expected ) ) ? $expected['md5'] : $expected;
4944 if ( is_array( $md5 ) ) {
4945 return implode( ', ', array_map( 'strval', $md5 ) );
4946 }
4947 return (string) $md5;
4948 }
4949
4950 /**
4951 * Whether a plugin/theme slug is inside its post-update grace window.
4952 *
4953 * Set by on_upgrade_complete() right after WordPress finishes updating a
4954 * plugin or theme. During the window the checksum cache is bypassed (so a
4955 * stale manifest cached during wp.org's propagation lag is never reused) and
4956 * the scheduled scan skips the slug (the dedicated post-update verifier
4957 * handles it instead), which is what stops the "files don't match
4958 * WordPress.org" false positives right after an update.
4959 *
4960 * @param string $type 'plugin' or 'theme'.
4961 * @param string $slug Slug.
4962 * @return bool
4963 */
4964 private function in_post_update_grace( $type, $slug ) {
4965 return (bool) get_transient( 'vigilante_fi_grace_' . $type . '_' . md5( $slug ) );
4966 }
4967
4968 /**
4969 * Get plugin checksums from WordPress.org
4970 *
4971 * @param string $slug Plugin slug.
4972 * @param string $version Plugin version.
4973 * @return array|WP_Error|string
4974 */
4975 private function get_plugin_checksums( $slug, $version ) {
4976 $cache_key = 'vigilante_plugin_checksums_' . md5( $slug . $version );
4977
4978 // During the post-update grace window, bypass the cache entirely so a
4979 // manifest cached while wp.org was still propagating the new version's
4980 // checksums can never be reused. Fetch fresh and do not write it back.
4981 $grace = $this->in_post_update_grace( 'plugin', $slug );
4982
4983 if ( ! $grace ) {
4984 $cached = get_transient( $cache_key );
4985 if ( false !== $cached ) {
4986 return $cached;
4987 }
4988 }
4989
4990 $url = sprintf(
4991 'https://downloads.wordpress.org/plugin-checksums/%s/%s.json',
4992 $slug,
4993 $version
4994 );
4995
4996 $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
4997
4998 if ( is_wp_error( $response ) ) {
4999 return $response;
5000 }
5001
5002 $status = wp_remote_retrieve_response_code( $response );
5003 if ( 404 === (int) $status ) {
5004 // Cache "not found" to avoid repeated requests (never during grace).
5005 if ( ! $grace ) {
5006 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
5007 }
5008 return 'not_found';
5009 }
5010
5011 if ( 200 !== (int) $status ) {
5012 // WordPress.org answered, but not with the checksums. An outage is
5013 // not a plugin with no published copy, and until now it passed
5014 // for one: the plugin was quietly left uncompared.
5015 return new WP_Error(
5016 'http_error',
5017 /* translators: %d: HTTP status code */
5018 sprintf( __( 'WordPress.org answered with status %d', 'vigilante' ), (int) $status )
5019 );
5020 }
5021
5022 $body = json_decode( wp_remote_retrieve_body( $response ), true );
5023
5024 if ( empty( $body['files'] ) ) {
5025 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
5026 }
5027
5028 // Store the full per-file record (md5 + sha256). Either value can be a
5029 // string or an array of strings; hash_matches_published() handles both.
5030 $checksums = array();
5031 foreach ( $body['files'] as $file => $data ) {
5032 $checksums[ $file ] = array(
5033 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
5034 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
5035 );
5036 }
5037
5038 // Cache for 24 hours (never during grace, to avoid persisting a manifest
5039 // wp.org may still be regenerating).
5040 if ( ! $grace ) {
5041 set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
5042 }
5043
5044 return $checksums;
5045 }
5046
5047 /**
5048 * Scan themes for modifications
5049 *
5050 * @return array Scan results.
5051 */
5052 private function scan_themes() {
5053 $results = array(
5054 'scanned' => 0,
5055 'ok' => 0,
5056 'modified' => array(),
5057 'suspicious' => array(),
5058 'extra' => array(),
5059 'errors' => array(),
5060 );
5061
5062 $themes = wp_get_themes();
5063 $finished = array();
5064
5065 foreach ( $themes as $theme_slug => $theme ) {
5066 // Check time limit
5067 if ( $this->is_time_exceeded() ) {
5068 break;
5069 }
5070
5071 // Skip slugs in their post-update grace window (see scan_plugins()).
5072 if ( $this->in_post_update_grace( 'theme', $theme_slug ) ) {
5073 continue;
5074 }
5075
5076 $version = $theme->get( 'Version' );
5077 $checksums = $this->get_theme_checksums( $theme_slug, $version );
5078
5079 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
5080
5081 // Same as for plugins: name what could not be compared, and why.
5082 if ( is_wp_error( $checksums ) && 'not_found' !== $checksums->get_error_code() ) {
5083 $results['errors'][] = sprintf(
5084 /* translators: 1: Theme name, 2: What went wrong */
5085 __( 'The theme %1$s could not be compared with its copy on WordPress.org: %2$s', 'vigilante' ),
5086 $theme->get( 'Name' ),
5087 $checksums->get_error_message()
5088 );
5089 } elseif ( ! $has_checksums ) {
5090 $results['unverified'][] = $theme->get( 'Name' );
5091 }
5092
5093 $theme_dir = $theme->get_stylesheet_directory();
5094
5095 // Check known files against checksums (only if available)
5096 if ( $has_checksums ) {
5097 foreach ( $checksums as $file => $expected_hash ) {
5098 // Check time limit inside inner loop too
5099 if ( $this->is_time_exceeded() ) {
5100 break 2; // Break both loops
5101 }
5102
5103 $file_path = $theme_dir . '/' . $file;
5104
5105 // Skip excluded paths
5106 if ( $this->is_path_excluded( $file_path ) ) {
5107 continue;
5108 }
5109
5110 // Skip excluded extensions
5111 if ( $this->is_extension_excluded( $file_path ) ) {
5112 continue;
5113 }
5114
5115 // Skip known false positives (e.g. readme.txt, readme.md)
5116 if ( in_array( $file, $this->plugin_known_false_positives, true ) ) {
5117 continue;
5118 }
5119
5120 $results['scanned']++;
5121
5122 if ( ! file_exists( $file_path ) ) {
5123 $results['missing'][] = array(
5124 'file' => 'themes/' . $theme_slug . '/' . $file,
5125 'type' => 'theme',
5126 'theme' => $theme->get( 'Name' ),
5127 );
5128 continue;
5129 }
5130
5131 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
5132 $results['modified'][] = array(
5133 'file' => 'themes/' . $theme_slug . '/' . $file,
5134 'type' => 'theme',
5135 'theme' => $theme->get( 'Name' ),
5136 'expected_hash' => $this->expected_hash_label( $expected_hash ),
5137 'actual_hash' => md5_file( $file_path ),
5138 );
5139 } else {
5140 $results['ok']++;
5141 }
5142 }
5143 } // end if $has_checksums
5144
5145 // Detect extra/suspicious files
5146 if ( ! $this->is_time_exceeded() ) {
5147 $known_files = $has_checksums ? $checksums : array();
5148 $suspicious_only = ! $has_checksums;
5149 $extra_results = $this->detect_extra_files( $theme_dir, $known_files, 'theme', $theme->get( 'Name' ), $suspicious_only );
5150 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
5151 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
5152 $results['errors'] = array_merge( $results['errors'] ?? array(), $extra_results['errors'] ?? array() );
5153 }
5154
5155 // Same as for plugins: a whole turn, with time left.
5156 if ( ! $this->is_time_exceeded() ) {
5157 $finished[ $theme_slug ] = true;
5158
5159 if ( ! $this->is_path_excluded( $theme_dir . '/.' ) ) {
5160 $results['checked'][] = $this->relative_path( $theme_dir );
5161 }
5162 }
5163 }
5164
5165 // The themes the time did not reach, by name.
5166 $results['not_checked'] = array();
5167
5168 foreach ( $themes as $theme_slug => $theme ) {
5169 if ( isset( $finished[ $theme_slug ] ) || $this->in_post_update_grace( 'theme', $theme_slug ) ) {
5170 continue;
5171 }
5172
5173 $results['not_checked'][] = (string) $theme->get( 'Name' );
5174 }
5175
5176 return $results;
5177 }
5178
5179 /**
5180 * Get theme checksums from WordPress.org
5181 *
5182 * @param string $slug Theme slug.
5183 * @param string $version Theme version.
5184 * @return array|WP_Error
5185 */
5186 private function get_theme_checksums( $slug, $version ) {
5187 $cache_key = 'vigilante_theme_package_' . md5( $slug . $version );
5188
5189 // Bypass the cache during the post-update grace window (see plugin path).
5190 $grace = $this->in_post_update_grace( 'theme', $slug );
5191
5192 if ( ! $grace ) {
5193 $cached = get_transient( $cache_key );
5194 if ( false !== $cached ) {
5195 return $cached;
5196 }
5197 }
5198
5199 /*
5200 * WordPress.org publishes checksums for plugins and for core, and none
5201 * for themes: the address this method asked until now answers 404 for
5202 * every theme, so no theme was ever compared with anything and they
5203 * were all treated as themes from elsewhere. What WordPress.org does
5204 * publish is the package of each version, so the checksums are taken
5205 * from there: the package is downloaded once per version, each file in
5206 * it is hashed straight from the archive, nothing is unpacked to disk,
5207 * and the result is kept for a month.
5208 */
5209 if ( '' === (string) $version || ! preg_match( '/^[A-Za-z0-9._-]+$/', (string) $slug ) ) {
5210 return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) );
5211 }
5212
5213 if ( ! class_exists( 'ZipArchive' ) ) {
5214 return new WP_Error( 'no_zip', __( 'the zip extension of PHP is missing on this server', 'vigilante' ) );
5215 }
5216
5217 if ( ! function_exists( 'download_url' ) ) {
5218 require_once ABSPATH . 'wp-admin/includes/file.php';
5219 }
5220
5221 $package = download_url(
5222 sprintf( 'https://downloads.wordpress.org/theme/%s.%s.zip', rawurlencode( $slug ), rawurlencode( (string) $version ) ),
5223 30
5224 );
5225
5226 if ( is_wp_error( $package ) ) {
5227 $data = $package->get_error_data();
5228
5229 // WordPress.org has no such theme or no such version: a theme from
5230 // somewhere else. Anything other than a 404 is a failed download,
5231 // and is reported as that instead of passing for a theme with no
5232 // official copy.
5233 if ( is_array( $data ) && isset( $data['code'] ) && 404 === (int) $data['code'] ) {
5234 if ( ! $grace ) {
5235 set_transient( $cache_key, 'not_found', 12 * HOUR_IN_SECONDS );
5236 }
5237 return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) );
5238 }
5239
5240 return $package;
5241 }
5242
5243 $checksums = array();
5244 $archive = new ZipArchive();
5245
5246 if ( true === $archive->open( $package ) ) {
5247 $prefix = $slug . '/';
5248 $max_entry = 16 * MB_IN_BYTES;
5249
5250 for ( $i = 0; $i < $archive->numFiles; $i++ ) {
5251 $entry = $archive->statIndex( $i );
5252 $name = is_array( $entry ) ? (string) $entry['name'] : '';
5253
5254 // Everything in the package lives under the folder of the theme.
5255 // Folders, anything outside it and any path with a ".."
5256 // segment are not files of the theme.
5257 if ( '' === $name || '/' === substr( $name, -1 ) || 0 !== strpos( $name, $prefix ) || preg_match( '#(?:^|/)\.\.(?:/|$)#', $name ) ) {
5258 continue;
5259 }
5260
5261 // Read up to a limit: an entry larger than this is not code,
5262 // and is left out of the comparison instead of being loaded
5263 // whole into memory.
5264 $content = $archive->getFromIndex( $i, $max_entry + 1 );
5265
5266 if ( false === $content ) {
5267 $checksums = array();
5268 break;
5269 }
5270
5271 if ( strlen( $content ) > $max_entry ) {
5272 continue;
5273 }
5274
5275 $checksums[ substr( $name, strlen( $prefix ) ) ] = array(
5276 'md5' => md5( $content ),
5277 'sha256' => hash( 'sha256', $content ),
5278 );
5279 }
5280
5281 $archive->close();
5282 }
5283
5284 wp_delete_file( $package );
5285
5286 if ( empty( $checksums ) ) {
5287 return new WP_Error( 'no_checksums', __( 'the package from WordPress.org could not be read', 'vigilante' ) );
5288 }
5289
5290 // The package of a published version does not change, so it is kept
5291 // for a month (never during grace).
5292 if ( ! $grace ) {
5293 set_transient( $cache_key, $checksums, MONTH_IN_SECONDS );
5294 }
5295
5296 return $checksums;
5297 }
5298
5299 /**
5300 * Scan uploads directory for suspicious files
5301 *
5302 * @return array Array with 'suspicious' and 'extra' sub-arrays.
5303 */
5304 private function scan_uploads() {
5305 $found = array(
5306 'suspicious' => array(),
5307 'extra' => array(),
5308 'errors' => array(),
5309 );
5310 $upload_dir = wp_upload_dir();
5311 $base_dir = $upload_dir['basedir'];
5312 $files_checked = 0;
5313
5314 // The walk used to stop after 10,000 files without saying so, and a
5315 // media library passes that with a few thousand images. Now it goes on
5316 // for as long as its share of the scan time lasts, and says so when it
5317 // is cut short. The share keeps a huge library from leaving the core
5318 // and the plugins without their turn.
5319 $deadline = $this->scan_start_time > 0 ? $this->scan_start_time + ( 0.4 * $this->max_scan_time ) : 0;
5320
5321 // Executable extensions that should never be in uploads
5322 $dangerous_extensions = self::php_extensions();
5323
5324 // Files that change how PHP runs in the folder they are in.
5325 $php_config_files = array( '.user.ini', 'php.ini' );
5326
5327 // Scripts a server can be told to run through CGI.
5328 $script_extensions = array( 'cgi', 'pl', 'py', 'sh', 'shtml' );
5329
5330 if ( ! is_dir( $base_dir ) ) {
5331 return $found;
5332 }
5333
5334 try {
5335 foreach ( self::walk_folder( $base_dir ) as $file ) {
5336 // Check global time limit
5337 if ( $this->is_time_exceeded() ) {
5338 break;
5339 }
5340
5341 if ( $file->isDir() ) {
5342 if ( ! $file->isReadable() ) {
5343 $found['errors'][] = self::unreadable_folder_error( $file->getPathname() );
5344 }
5345 continue;
5346 }
5347
5348 // Check the share of the scan time (every 500 files)
5349 $files_checked++;
5350 if ( $deadline > 0 && 0 === $files_checked % 500 && microtime( true ) > $deadline ) {
5351 $found['errors'][] = sprintf(
5352 /* translators: %s: Number of files */
5353 __( 'The uploads folder was not checked completely: the time for it ran out after %s files.', 'vigilante' ),
5354 number_format_i18n( $files_checked )
5355 );
5356 break;
5357 }
5358
5359 $file_path = $file->getPathname();
5360 $basename = basename( $file_path );
5361 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
5362 $relative = str_replace( ABSPATH, '', $file_path );
5363
5364 // Skip excluded paths
5365 if ( $this->is_path_excluded( $file_path ) ) {
5366 continue;
5367 }
5368
5369 // 1. Check for PHP files in uploads (most important security check)
5370 if ( in_array( $extension, $dangerous_extensions, true ) ) {
5371 // Silence-is-golden placeholders are dropped by WordPress and many
5372 // plugins into upload subfolders to block directory listings.
5373 // Whitelist by content so an attacker can't bypass the rule with
5374 // a payload named index.php.
5375 if ( $this->is_silence_golden_file( $file_path ) ) {
5376 continue;
5377 }
5378
5379 $reason = __( 'PHP file found in uploads directory', 'vigilante' );
5380
5381 // Scan content for specific suspicious patterns
5382 $pattern = $this->search_file( $file_path );
5383 if ( $pattern ) {
5384 /* translators: %s: Suspicious pattern found */
5385 $reason = sprintf( __( 'PHP in uploads with suspicious code: %s', 'vigilante' ), $pattern );
5386 }
5387
5388 $found['suspicious'][] = array(
5389 'file' => $relative,
5390 'type' => 'php_in_uploads',
5391 'reason' => $reason,
5392 );
5393 continue;
5394 }
5395
5396 // 2. Check for double extensions (image.php.jpg, file.phtml.png)
5397 // The second extension can be anything: a script renamed by a
5398 // malware cleaner (shell.php.suspected) or numbered (shell.php.1)
5399 // is as much of a finding as one dressed up as an image.
5400 if ( preg_match( '/\.(' . implode( '|', $dangerous_extensions ) . ')\.[a-z0-9_~-]{1,16}$/i', $basename ) ) {
5401 $found['suspicious'][] = array(
5402 'file' => $relative,
5403 'type' => 'double_extension',
5404 'reason' => __( 'Double extension detected (possible disguised executable)', 'vigilante' ),
5405 );
5406 continue;
5407 }
5408
5409 // 2b. Files that change how PHP runs in this folder
5410 if ( in_array( strtolower( $basename ), $php_config_files, true ) ) {
5411 $found['suspicious'][] = array(
5412 'file' => $relative,
5413 'type' => 'php_config_in_uploads',
5414 'reason' => __( 'PHP configuration file in uploads', 'vigilante' ),
5415 );
5416 continue;
5417 }
5418
5419 // 2c. Scripts in other languages
5420 if ( in_array( $extension, $script_extensions, true ) ) {
5421 $found['extra'][] = array(
5422 'file' => $relative,
5423 'type' => 'script_in_uploads',
5424 'reason' => __( 'Script file in uploads', 'vigilante' ),
5425 );
5426 continue;
5427 }
5428
5429 // 3. Check for .htaccess files in uploads
5430 // Read content to classify (see classify_htaccess_in_uploads())
5431 if ( '.htaccess' === $basename ) {
5432 $htaccess_result = $this->classify_htaccess_in_uploads( $file_path, $relative );
5433
5434 // One that only denies access, or does nothing, is not a finding.
5435 if ( 'none' !== $htaccess_result['category'] ) {
5436 $found[ $htaccess_result['category'] ][] = $htaccess_result['item'];
5437 }
5438 }
5439 }
5440 } catch ( Exception $e ) {
5441 $found['errors'][] = self::unreadable_folder_error( $base_dir );
5442 }
5443
5444 return $found;
5445 }
5446
5447 /**
5448 * Classify a .htaccess file found in uploads directory
5449 *
5450 * The file is read the way Apache reads it: continued lines are joined,
5451 * comments are dropped and each directive is looked at on its own. That
5452 * replaces a handful of expressions run over the whole text, which took a
5453 * commented line for a live one and the rule that forbids PHP for one that
5454 * runs it, and let through the directives that turn CGI on.
5455 *
5456 * - Something that makes the server run code: suspicious.
5457 * - Only directives that deny or switch something off: not reported.
5458 * - Rewrite rules of its own and nothing that denies PHP: extra, because in
5459 * that folder they replace the rules of the site root, the one that
5460 * blocks PHP in uploads included (measured on Apache, 3 Oct 2026).
5461 * - Anything else: extra, with what it contains.
5462 *
5463 * @param string $file_path Absolute file path.
5464 * @param string $relative Relative file path for display.
5465 * @return array Array with 'category' ('suspicious', 'extra' or 'none') and 'item' data.
5466 */
5467 private function classify_htaccess_in_uploads( $file_path, $relative ) {
5468 $content = false;
5469
5470 if ( filesize( $file_path ) < 65536 ) { // Only read files < 64KB
5471 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
5472 }
5473
5474 $item = array(
5475 'file' => $relative,
5476 'type' => 'htaccess_in_uploads',
5477 );
5478
5479 // A file that cannot be read cannot be vouched for.
5480 if ( false === $content ) {
5481 $item['reason'] = __( '.htaccess file in uploads directory that could not be read', 'vigilante' );
5482
5483 return array(
5484 'category' => 'suspicious',
5485 'item' => $item,
5486 );
5487 }
5488
5489 $facts = self::read_htaccess_rules( $content );
5490
5491 if ( ! empty( $facts['runs'] ) ) {
5492 $item['reason'] = self::htaccess_run_reason( $facts );
5493
5494 return array(
5495 'category' => 'suspicious',
5496 'item' => $item,
5497 );
5498 }
5499
5500 if ( $facts['rewrite'] && ! $facts['denies_php'] ) {
5501 $item['reason'] = __( '.htaccess in uploads with rewrite rules of its own: in this folder they replace the ones of the site root, the rule that blocks PHP in uploads included', 'vigilante' );
5502
5503 return array(
5504 'category' => 'extra',
5505 'item' => $item,
5506 );
5507 }
5508
5509 // Empty, or nothing in it that does not deny or switch something off:
5510 // it protects the folder, or does nothing, and is not a finding.
5511 if ( empty( $facts['other'] ) ) {
5512 return array(
5513 'category' => 'none',
5514 'item' => null,
5515 );
5516 }
5517
5518 $kinds = array(
5519 'access' => __( 'access rules', 'vigilante' ),
5520 'rewrite' => __( 'rewrite rules', 'vigilante' ),
5521 'headers' => __( 'cache or header rules', 'vigilante' ),
5522 'other' => __( 'other rules', 'vigilante' ),
5523 );
5524
5525 /* translators: %s: Summary of rules found in the .htaccess file */
5526 $item['reason'] = sprintf( __( '.htaccess in uploads (likely from plugin). Contains: %s', 'vigilante' ), implode( ', ', array_intersect_key( $kinds, $facts['other'] ) ) );
5527
5528 return array(
5529 'category' => 'extra',
5530 'item' => $item,
5531 );
5532 }
5533
5534 /**
5535 * The reason shown for a .htaccess that makes the server run code
5536 *
5537 * @param array $facts What read_htaccess_rules() found.
5538 * @return string
5539 */
5540 private static function htaccess_run_reason( $facts ) {
5541 $labels = array(
5542 'run' => __( 'makes the server run files of this folder as code', 'vigilante' ),
5543 'load' => __( 'loads a file on every PHP request', 'vigilante' ),
5544 'rewrite' => __( 'sends requests to a PHP file', 'vigilante' ),
5545 'cgi' => __( 'turns on CGI or server-side includes', 'vigilante' ),
5546 );
5547
5548 /* translators: %s: What the rule does */
5549 return sprintf( __( '.htaccess with a rule that affects how code runs: %s', 'vigilante' ), implode( '; ', array_intersect_key( $labels, $facts['runs'] ) ) );
5550 }
5551
5552 /**
5553 * What a section of a .htaccess says about the directives inside it
5554 *
5555 * @param string $name Section name, in lower case.
5556 * @param string $args What follows the name, as written.
5557 * @return string '' when it changes nothing (<RequireAll>), 'rewrite' for
5558 * <IfModule mod_rewrite.c>, 'maybe' for any other test of
5559 * what the server has, 'files-php' for a <Files> or
5560 * <FilesMatch> that names PHP files, 'files' for one that
5561 * names other files, and 'condition' for the rest (<Limit>,
5562 * <If>, <RequireAny>...), which apply to some requests only.
5563 */
5564 private static function htaccess_section_kind( $name, $args ) {
5565 $args = trim( rtrim( trim( $args ), '>' ) );
5566
5567 if ( 'requireall' === $name ) {
5568 return '';
5569 }
5570
5571 if ( 'ifmodule' === $name ) {
5572 return in_array( strtolower( trim( $args, '"\' ' ) ), array( 'mod_rewrite.c', 'rewrite_module' ), true ) ? 'rewrite' : 'maybe';
5573 }
5574
5575 if ( in_array( $name, array( 'ifdefine', 'ifversion' ), true ) ) {
5576 return 'maybe';
5577 }
5578
5579 if ( 'files' === $name || 'filesmatch' === $name ) {
5580 $regex = 'filesmatch' === $name;
5581
5582 if ( ! $regex && '~' === substr( $args, 0, 1 ) ) {
5583 $regex = true;
5584 $args = trim( substr( $args, 1 ) );
5585 }
5586
5587 return true === self::extension_pattern_names_php( trim( $args, '"\'' ), $regex, false ) ? 'files-php' : 'files';
5588 }
5589
5590 return 'condition';
5591 }
5592
5593 /**
5594 * Whether a pattern that goes only by the extension of a file names the PHP ones
5595 *
5596 * "Every PHP file" cannot be asked of a sample name. A sample is public,
5597 * so a section can be written for it and nothing else, and an expression
5598 * can name every file but one ("all but shell.php") in more ways than can
5599 * be tried. So the pattern has to be of a kind that cannot tell one name
5600 * from another: any name, a dot and a list of extensions, to the end. In
5601 * that kind there is nothing to make an exception with, and whether .php
5602 * is on the list is then a fair question for a sample. Anything else is
5603 * not taken for a denial of PHP, which only means the .htaccess is listed.
5604 *
5605 * @param string $pattern Pattern, without its quotes.
5606 * @param bool $regex Whether it is an expression (FilesMatch, "Files ~",
5607 * RewriteRule) or a wildcard (Files).
5608 * @param bool $nocase Whether it ignores case.
5609 * @return bool|null True when it names every .php file, false when it goes
5610 * by the extension and .php is not one of them, null
5611 * when it is not of that kind.
5612 */
5613 private static function extension_pattern_names_php( $pattern, $regex, $nocase ) {
5614 if ( ! $regex ) {
5615 if ( 1 !== preg_match( '/^\*\.[a-z0-9*?]+$/i', $pattern ) ) {
5616 return null;
5617 }
5618
5619 return function_exists( 'fnmatch' ) ? fnmatch( $pattern, 'x.php' ) : '*.php' === $pattern;
5620 }
5621
5622 $rest = $pattern;
5623
5624 if ( 0 === stripos( $rest, '(?i)' ) ) {
5625 $rest = substr( $rest, 4 );
5626 $nocase = true;
5627 }
5628
5629 // "Any name" in front, written out or left out, and then a dot, the
5630 // extensions and the end, written out or left out too. The extensions
5631 // are letters, digits, groups, alternatives and classes of letters and
5632 // digits: no dot, no "any character", nothing negated and nothing
5633 // that looks around.
5634 $rest = preg_replace( '/^\^?\.[*+]/', '', $rest, 1 );
5635
5636 if ( 1 !== preg_match( '/^\\\\\.(?:[a-z0-9|?*+)]|\\\\d|\[[a-z0-9-]+\]|\((?:\?i?:)?)+\$?$/i', (string) $rest ) ) {
5637 return null;
5638 }
5639
5640 // The dot has to be there: with "?" or "*" after it, what follows
5641 // would be looked for anywhere in the name. And an alternative
5642 // outside a group is a second pattern with no dot in front of it,
5643 // which goes by the name again.
5644 if ( 1 === preg_match( '/^\\\\\.[?*+]/', $rest ) ) {
5645 return null;
5646 }
5647
5648 $depth = 0;
5649
5650 for ( $i = 0, $length = strlen( $rest ); $i < $length; $i++ ) {
5651 if ( '(' === $rest[ $i ] ) {
5652 $depth++;
5653 } elseif ( ')' === $rest[ $i ] ) {
5654 $depth--;
5655 }
5656
5657 if ( $depth < 0 || ( 0 === $depth && '|' === $rest[ $i ] ) ) {
5658 return null;
5659 }
5660 }
5661
5662 if ( 0 !== $depth ) {
5663 return null;
5664 }
5665
5666 $expression = '~' . str_replace( '~', '\\~', $pattern ) . '~' . ( $nocase ? 'i' : '' );
5667
5668 foreach ( array( 'x.php', 'a/b/x.php' ) as $sample ) {
5669 $matched = @preg_match( $expression, $sample ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A pattern Apache would refuse names nothing.
5670
5671 if ( false === $matched ) {
5672 return null;
5673 }
5674
5675 if ( 0 === $matched ) {
5676 return false;
5677 }
5678 }
5679
5680 return true;
5681 }
5682
5683 /**
5684 * Whether a RewriteRule that refuses requests refuses every PHP file
5685 *
5686 * A pattern with a "!" in front refuses what it does not match, so it
5687 * refuses PHP when it goes by the extension and .php is not one of them
5688 * ("!\.(jpe?g|png)$": everything but the images).
5689 *
5690 * @param string $pattern Pattern of the rule.
5691 * @param string $flags Flags of the rule, in lower case.
5692 * @return bool
5693 */
5694 private static function rewrite_rule_refuses_php( $pattern, $flags ) {
5695 $pattern = trim( $pattern, '"\'' );
5696 $negated = '' !== $pattern && '!' === $pattern[0];
5697
5698 if ( $negated ) {
5699 $pattern = substr( $pattern, 1 );
5700 }
5701
5702 if ( '' === $pattern ) {
5703 return false;
5704 }
5705
5706 $names = self::extension_pattern_names_php( $pattern, true, (bool) preg_match( '/[\[,]\s*(?:nc|nocase)\s*[,\]]/', $flags ) );
5707
5708 return $negated ? false === $names : true === $names;
5709 }
5710
5711 /**
5712 * What the directives of a .htaccess do, as far as running code goes
5713 *
5714 * @param string $content Content of the file.
5715 * @return array {
5716 * @type array $runs Ways it makes the server run code, as keys:
5717 * 'run', 'load', 'rewrite', 'cgi'.
5718 * @type array $other Kinds of directive that neither run code nor
5719 * only deny, as keys: 'access', 'rewrite',
5720 * 'headers', 'other'.
5721 * @type bool $rewrite Whether it has rewrite directives of its own.
5722 * @type bool $denies_php Whether it denies access to every PHP file
5723 * itself, for every request and whatever the
5724 * server has loaded.
5725 * }
5726 */
5727 private static function read_htaccess_rules( $content ) {
5728 $facts = array(
5729 'runs' => array(),
5730 'other' => array(),
5731 'rewrite' => false,
5732 'denies_php' => false,
5733 );
5734
5735 // A line that ends in a backslash goes on in the next one, and Apache
5736 // joins the two before it looks at anything else, comments included.
5737 $content = str_replace( array( "\r\n", "\r" ), "\n", (string) $content );
5738 $content = preg_replace( '/(?<!\\\\)\\\\\n/', '', $content );
5739
5740 // "script" as a word of its own: cgi-script and php-script are
5741 // handlers that run code, application/javascript is a kind of file.
5742 $scripts = '/php|cgi|(?<![a-z])script|lsapi|server-parsed|proxy:|python-program/';
5743 $php_target = '/\.(?:ph(?:p\d*|tml|t|ar|ps)|inc)(?![a-z0-9])/';
5744 $containers = array();
5745 $cgi_handler = false;
5746 $execcgi_on = false;
5747 $execcgi_off = false;
5748
5749 foreach ( explode( "\n", (string) $content ) as $line ) {
5750 $line = trim( $line );
5751
5752 if ( '' === $line || '#' === $line[0] ) {
5753 continue;
5754 }
5755
5756 // Sections are remembered, to know what a directive inside them
5757 // applies to (see htaccess_section_kind()).
5758 if ( '<' === $line[0] ) {
5759 if ( preg_match( '/^<\s*\//', $line ) ) {
5760 array_pop( $containers );
5761 } elseif ( preg_match( '/^<\s*([A-Za-z]+)\b(.*)$/', $line, $section ) ) {
5762 $containers[] = self::htaccess_section_kind( strtolower( $section[1] ), $section[2] );
5763 }
5764 continue;
5765 }
5766
5767 if ( ! preg_match( '/^([A-Za-z_]+)\s*(.*)$/', $line, $parts ) ) {
5768 $facts['other']['other'] = true;
5769 continue;
5770 }
5771
5772 $name = strtolower( $parts[1] );
5773 $args = trim( $parts[2] );
5774 $low = strtolower( $args );
5775 $words = preg_split( '/\s+/', $low, -1, PREG_SPLIT_NO_EMPTY );
5776 $first = isset( $words[0] ) ? trim( $words[0], '"\'' ) : '';
5777
5778 // The sections around this line that put a condition on it.
5779 $inside = array_values( array_diff( $containers, array( '' ) ) );
5780
5781 switch ( $name ) {
5782 case 'sethandler':
5783 case 'addhandler':
5784 case 'addtype':
5785 case 'forcetype':
5786 if ( preg_match( $scripts, $first ) ) {
5787 // "AddHandler cgi-script" next to "Options -ExecCGI" is
5788 // the old recipe that hands every script to CGI and
5789 // forbids CGI. Which of the two it is gets decided
5790 // once the whole file has been read.
5791 if ( 'addhandler' === $name && false !== strpos( $first, 'cgi' ) ) {
5792 $cgi_handler = true;
5793 } else {
5794 $facts['runs']['run'] = true;
5795 }
5796 } elseif ( ! in_array( $first, array( 'none', 'default-handler', 'text/plain', 'application/octet-stream' ), true ) ) {
5797 $facts['other']['other'] = true;
5798 }
5799 break;
5800
5801 case 'php_flag':
5802 case 'php_admin_flag':
5803 case 'php_value':
5804 case 'php_admin_value':
5805 $value = isset( $words[1] ) ? trim( $words[1], '"\'' ) : '';
5806
5807 if ( 'engine' === $first ) {
5808 if ( ! in_array( $value, array( 'off', '0', 'false', 'no' ), true ) ) {
5809 $facts['runs']['run'] = true;
5810 }
5811 } elseif ( in_array( $first, array( 'auto_prepend_file', 'auto_append_file' ), true ) ) {
5812 if ( '' !== $value && 'none' !== $value ) {
5813 $facts['runs']['load'] = true;
5814 }
5815 } else {
5816 $facts['other']['other'] = true;
5817 }
5818 break;
5819
5820 case 'options':
5821 foreach ( $words as $option ) {
5822 // Only a ban on CGI that holds for everybody counts as one.
5823 if ( '-execcgi' === $option && empty( $inside ) ) {
5824 $execcgi_off = true;
5825 }
5826
5827 if ( '-' === $option[0] || 'none' === $option ) {
5828 continue;
5829 }
5830
5831 $option = ltrim( $option, '+' );
5832
5833 if ( in_array( $option, array( 'execcgi', 'includes', 'all' ), true ) ) {
5834 $facts['runs']['cgi'] = true;
5835 $execcgi_on = true;
5836 } else {
5837 $facts['other']['other'] = true;
5838 }
5839 }
5840 break;
5841
5842 case 'addoutputfilter':
5843 case 'setoutputfilter':
5844 case 'addoutputfilterbytype':
5845 if ( false !== strpos( $low, 'includes' ) ) {
5846 $facts['runs']['cgi'] = true;
5847 } else {
5848 $facts['other']['other'] = true;
5849 }
5850 break;
5851
5852 case 'action':
5853 $facts['runs']['cgi'] = true;
5854 break;
5855
5856 case 'xbithack':
5857 // Server-side includes for every file with the execute bit.
5858 if ( 'off' !== $first ) {
5859 $facts['runs']['cgi'] = true;
5860 }
5861 break;
5862
5863 case 'deny':
5864 case 'require':
5865 $denies = 'deny' === $name ? ( 'from' === $first && isset( $words[1] ) && 'all' === $words[1] ) : ( 'all' === $first && isset( $words[1] ) && 'denied' === $words[1] );
5866
5867 if ( ! $denies || in_array( 'condition', $inside, true ) ) {
5868 $facts['other']['access'] = true;
5869 } elseif ( ! array_diff( $inside, array( 'rewrite', 'files-php' ) ) ) {
5870 // For every request, on any server, and for every file
5871 // or for the PHP ones. A denial that depends on a
5872 // module being there, or that names other files, still
5873 // protects, but it does not make up for rewrite rules
5874 // that switch off the ones of the site root.
5875 $facts['denies_php'] = true;
5876 }
5877 break;
5878
5879 case 'order':
5880 case 'indexignore':
5881 break;
5882
5883 case 'allow':
5884 case 'satisfy':
5885 case 'authtype':
5886 case 'authname':
5887 case 'authuserfile':
5888 case 'authgroupfile':
5889 $facts['other']['access'] = true;
5890 break;
5891
5892 case 'rewriteengine':
5893 case 'rewritebase':
5894 case 'rewriteoptions':
5895 $facts['rewrite'] = true;
5896 break;
5897
5898 case 'rewritecond':
5899 // A condition makes the rule after it apply to some
5900 // requests and not to others, a denial included.
5901 $facts['rewrite'] = true;
5902 $facts['other']['rewrite'] = true;
5903 break;
5904
5905 case 'rewriterule':
5906 $facts['rewrite'] = true;
5907
5908 $rule = preg_split( '/\s+/', $args, 3 );
5909 $pattern = isset( $rule[0] ) ? $rule[0] : '';
5910 $target = isset( $rule[1] ) ? strtolower( trim( $rule[1], '"\'' ) ) : '';
5911 $flags = isset( $rule[2] ) ? strtolower( $rule[2] ) : '';
5912
5913 if ( preg_match( '/[\[,]\s*h=/', $flags ) || preg_match( '/[\[,]\s*t=[^,\]]*(?:php|cgi|(?<![a-z])script)/', $flags ) ) {
5914 // A handler or a type forced from the rule itself.
5915 $facts['runs']['run'] = true;
5916 } elseif ( '-' === $target && preg_match( '/[\[,]\s*(?:f|forbidden|g|gone)\s*[,\]]/', $flags ) ) {
5917 // Rewrites nothing and refuses the request. It stands
5918 // for the rule of the site root only when it refuses
5919 // every PHP file, for every request.
5920 if ( ! array_diff( $inside, array( 'rewrite' ) ) && self::rewrite_rule_refuses_php( $pattern, $flags ) ) {
5921 $facts['denies_php'] = true;
5922 } else {
5923 $facts['other']['rewrite'] = true;
5924 }
5925 } elseif ( '-' === $target ) {
5926 // Rewrites nothing and lets the request through, which
5927 // can be the way to keep one file out of the denial
5928 // that follows: it is not part of a file that only
5929 // protects.
5930 $facts['other']['rewrite'] = true;
5931 } elseif ( preg_match( $php_target, $target ) && '/index.php' !== $target ) {
5932 $facts['runs']['rewrite'] = true;
5933 } else {
5934 $facts['other']['rewrite'] = true;
5935 }
5936 break;
5937
5938 case 'header':
5939 case 'requestheader':
5940 case 'expiresactive':
5941 case 'expiresbytype':
5942 case 'expiresdefault':
5943 case 'fileetag':
5944 $facts['other']['headers'] = true;
5945 break;
5946
5947 default:
5948 $facts['other']['other'] = true;
5949 }
5950 }
5951
5952 if ( $cgi_handler && ( $execcgi_on || ! $execcgi_off ) ) {
5953 $facts['runs']['cgi'] = true;
5954 }
5955
5956 return $facts;
5957 }
5958
5959 /**
5960 * Check content for suspicious patterns
5961 *
5962 * @param string $content File content.
5963 * @return bool
5964 */
5965 private function has_suspicious_content( $content ) {
5966 return (bool) $this->detect_suspicious_pattern( $content );
5967 }
5968
5969 /**
5970 * Detect specific suspicious pattern in file content
5971 *
5972 * Two detection levels:
5973 * - Standard (strict=false): for uploads where ANY PHP is already suspicious.
5974 * Single-function matches like dangerous functions, superglobals are enough.
5975 * - Strict (strict=true): for plugins/themes without checksums where PHP is expected.
5976 * Only flags clear obfuscation combos to avoid false positives on legitimate code.
5977 *
5978 * Patterns are loaded from an external JSON file (scan-patterns.json)
5979 * with base64-encoded needles to prevent WAF/antimalware false positives
5980 * on the scanner file itself.
5981 *
5982 * @param string $content File content.
5983 * @param bool $strict Use strict mode (fewer, higher-confidence patterns).
5984 * @return string|false The pattern found, or false.
5985 */
5986 private function detect_suspicious_pattern( $content, $strict = false ) {
5987
5988 if ( $strict ) {
5989 return $this->detect_strict_suspicious_pattern( $content );
5990 }
5991
5992 $patterns_data = $this->load_scan_patterns();
5993 if ( empty( $patterns_data['standard_patterns'] ) ) {
5994 return false;
5995 }
5996
5997 // Standard mode: broad detection for uploads and known-extra files
5998 foreach ( $patterns_data['standard_patterns'] as $encoded_needle => $label ) {
5999 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding pattern definitions, not user input.
6000 $needle = base64_decode( $encoded_needle );
6001 if ( $this->needle_present( $content, $needle ) ) {
6002 // The labels live in the pattern file, in English: "$_GET access"
6003 // becomes "$_GET", which needs no translation.
6004 return preg_replace( '/ access$/', '', (string) $label );
6005 }
6006 }
6007
6008 return $this->detect_common_pattern( $content );
6009 }
6010
6011 /**
6012 * The three checks both levels of the search share
6013 *
6014 * @param string $content File content.
6015 * @return string|false The pattern found, or false.
6016 */
6017 private function detect_common_pattern( $content ) {
6018 // preg_replace with the /e modifier (arbitrary code execution,
6019 // deprecated). The modifier is looked for on the same line and within
6020 // 300 characters of the opening quote. With ".*" every preg_replace of
6021 // a long line cost a pass over the whole line: 88 seconds for a file
6022 // of 500 KB made for it, measured without the PCRE compiler.
6023 if ( $this->found( '/preg_replace\s*+\(\s*+[\'"][^\n]{0,300}?\/e[\'"]/i', $content ) ) {
6024 return __( 'preg_replace with the /e modifier, which runs code', 'vigilante' );
6025 }
6026
6027 // Long hex-encoded strings (obfuscated payloads). Eleven escapes in a
6028 // row and not "ten or more after the first": asked to take them all,
6029 // PCRE gave up on a run of some 12,000, and a search that gave up
6030 // read as "nothing found". The longer the payload, the less it was
6031 // found.
6032 if ( $this->found( '/(?:\\\\x[0-9a-f]{2}){11}/i', $content ) ) {
6033 return __( 'a long string written in hexadecimal', 'vigilante' );
6034 }
6035
6036 // Heavily concatenated chr() calls (char-by-char obfuscation)
6037 if ( $this->found( '/chr\s*+\(\s*+\d++\s*+\)\s*+\.\s*+chr\s*+\(\s*+\d++\s*+\)\s*+\.\s*+chr/i', $content ) ) {
6038 return __( 'text built character by character with chr()', 'vigilante' );
6039 }
6040
6041 return false;
6042 }
6043
6044 /**
6045 * Strict suspicious pattern detection for plugins/themes without checksums
6046 *
6047 * Only flags high-confidence obfuscation combos that are almost certainly malware.
6048 * Individual functions are normal in plugins and are not flagged.
6049 *
6050 * @param string $content File content.
6051 * @return string|false The pattern found, or false.
6052 */
6053 private function detect_strict_suspicious_pattern( $content ) {
6054
6055 $patterns_data = $this->load_scan_patterns();
6056 if ( empty( $patterns_data['strict_fragments'] ) ) {
6057 return false;
6058 }
6059
6060 // Decode fragment names from JSON
6061 $fragments = array();
6062 foreach ( $patterns_data['strict_fragments'] as $key => $encoded ) {
6063 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
6064 $fragments[ $key ] = base64_decode( $encoded );
6065 }
6066
6067 $ev = $fragments['ev'] ?? '';
6068 $b6 = $fragments['b6'] ?? '';
6069 $gz = $fragments['gz'] ?? '';
6070 $gu = $fragments['gu'] ?? '';
6071 $sr = $fragments['sr'] ?? '';
6072 $hb = $fragments['hb'] ?? '';
6073 $as = $fragments['as'] ?? '';
6074 $ss = $fragments['ss'] ?? '';
6075 $cf = $fragments['cf'] ?? '';
6076
6077 // Obfuscation combos: dangerous function wrapping decoded content.
6078 // The labels name the two functions side by side and not one inside
6079 // the other. Written the way the code is written, a label was itself
6080 // a match: the results of a scan, kept in a cache that stores options
6081 // as PHP files under wp-content, would have been the next finding.
6082 // The possessive quantifiers ("*+") change nothing in what matches,
6083 // because what follows each run of spaces is never a space. They keep
6084 // PCRE from walking a long run back one space at a time, which is
6085 // where it gives up when it runs without its compiler.
6086 $obfuscation_combos = array(
6087 '/' . $ev . '\s*+\(\s*+' . $b6 . '\s*+\(/i' => $ev . '() + ' . $b6 . '()',
6088 '/' . $ev . '\s*+\(\s*+' . $gz . '\s*+\(/i' => $ev . '() + ' . $gz . '()',
6089 '/' . $ev . '\s*+\(\s*+' . $gu . '\s*+\(/i' => $ev . '() + ' . $gu . '()',
6090 '/' . $ev . '\s*+\(\s*+' . $sr . '\s*+\(/i' => $ev . '() + ' . $sr . '()',
6091 '/' . $ev . '\s*+\(\s*+' . $hb . '\s*+\(/i' => $ev . '() + ' . $hb . '()',
6092 '/' . $as . '\s*+\(\s*+' . $b6 . '\s*+\(/i' => $as . '() + ' . $b6 . '()',
6093 '/' . $ev . '\s*+\(\s*+\$[a-z_]++\s*+\(/i' => $ev . '() + $variable()',
6094 '/' . $ev . '\s*+\(\s*+' . $ss . '\s*+\(/i' => $ev . '() + ' . $ss . '()',
6095 );
6096
6097 foreach ( $obfuscation_combos as $regex => $label ) {
6098 if ( $this->found( $regex, $content ) ) {
6099 return $label;
6100 }
6101 }
6102
6103 // The plain backdoor, with nothing to hide: data from the request
6104 // handed straight to code or to the system, a function the request
6105 // chooses, or a file included from another server. The names come
6106 // from the list the concatenation check below uses, minus the decoders,
6107 // so they are not written here either. That list is decoded once, for both.
6108 $dangerous_names = array();
6109 if ( ! empty( $patterns_data['dangerous_names'] ) ) {
6110 foreach ( $patterns_data['dangerous_names'] as $encoded_name ) {
6111 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
6112 $dangerous_names[] = base64_decode( $encoded_name );
6113 }
6114 }
6115
6116 $runners = array_merge( array_filter( array( $ev, $as ) ), array_diff( $dangerous_names, array( $b6, $gz, $gu, $sr, $hb ) ) );
6117
6118 // These three read as code what PHP reads as code, so they are
6119 // searched with the comments of the file turned into blank space
6120 // (blank_comments()). A comment that quotes one of them ("never do
6121 // this: ...") is then not a finding, and a comment slipped between a
6122 // name and its parenthesis hides nothing. The first two cannot match
6123 // without a superglobal of the request, so only the files that name
6124 // one are prepared that way, about one in twenty. The third is looked
6125 // for in the file as it is and checked again that way when it matches.
6126 $code = null;
6127
6128 if ( $this->found( '/\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\b/', $content ) ) {
6129 $code = $this->blank_comments( $content );
6130
6131 // When PHP cannot be asked what is a comment, the file is
6132 // searched as it is: a comment may then be reported, and nothing
6133 // that runs is left out.
6134 $subject = false === $code ? $content : $code;
6135
6136 if ( ! empty( $runners ) ) {
6137 $names = implode( '|', array_map( 'preg_quote', $runners ) );
6138 // What the visitor sends, when it is not just being asked about
6139 // with isset() or empty(). The path of an uploaded file is not in
6140 // the list: handing it to a command is how images get processed.
6141 $from_request = '(?<!isset\()(?<!isset\( )(?<!empty\()(?<!empty\( )(?:\$_(?:GET|POST|REQUEST|COOKIE)\b|\$_SERVER\s*\[\s*[\'"]HTTP_)';
6142
6143 // The possessive quantifiers keep a file made of spaces from
6144 // costing a try per space.
6145 if ( $this->found( '/(?<![a-z0-9_>:$])(?:' . $names . ')\s*+\(\s*+[^;\n]{0,80}?' . $from_request . '/i', $subject ) ) {
6146 return __( 'data from the request is run as code or as a command', 'vigilante' );
6147 }
6148 }
6149
6150 if ( $this->found( '/\$_(?:GET|POST|REQUEST|COOKIE)\s*+\[[^\]\n]{1,60}\]\s*+\(/', $subject ) ) {
6151 return __( 'a function chosen by the request is called', 'vigilante' );
6152 }
6153 }
6154
6155 if ( $this->found_in_code( '/(?<![a-z0-9_>$])(?:include|require)(?:_once)?\s*+(?:\(\s*+)?[\'"]https?:\/\//i', $content, $code ) ) {
6156 return __( 'a file from another server is included', 'vigilante' );
6157 }
6158
6159 // Deprecated dynamic function constructor, nearly always malicious in modern code
6160 if ( ! empty( $cf ) && $this->needle_present( $content, $cf ) ) {
6161 // The name alone, for the same reason as the labels above.
6162 return rtrim( $cf, '(' );
6163 }
6164
6165 // Remote fetch piped into unserialize: a PHP object-injection / supply-chain
6166 // vector seen in trojanized or nulled plugins (download a payload from a
6167 // remote URL and unserialize it). Requires a REAL unserialize() call
6168 // (not maybe_unserialize() / igbinary_unserialize(), which are common and
6169 // safe) sitting CLOSE TO a remote-fetch call. Earlier releases only
6170 // checked that both strings appeared somewhere in the file, which
6171 // false-positived on legitimate code using both in unrelated methods
6172 // (e.g. a theme reading a transient with maybe_unserialize() while
6173 // fetching its public IP with wp_remote_get()).
6174 //
6175 // The search runs on a copy of the file in lower case, with
6176 // expressions that tell the case apart, and only when the file names
6177 // the function at all. PCRE without its compiler looks ahead for the
6178 // first letter of an expression in both cases before each try, and
6179 // when one of the two is not in the file that look runs to the end of
6180 // it. pattern_near() asks once per match, so a file with thousands of
6181 // them cost thousands of passes (26 seconds for 8 MB, measured). With
6182 // one case to look for, each look stops at the next candidate.
6183 $us = self::lower_ascii( $fragments['us'] ?? '' );
6184 if ( '' !== $us && false !== stripos( $content, $us ) ) {
6185 $lower = self::lower_ascii( $content );
6186 $us_regex = '/(?<![a-z0-9_])' . preg_quote( $us, '/' ) . '\s*+\(/';
6187 $remote_fetchers = array(
6188 $fragments['wr'] ?? '', // wp_remote_get
6189 $fragments['rb'] ?? '', // wp_remote_retrieve_body
6190 $fragments['ce'] ?? '', // curl_exec
6191 );
6192 foreach ( $remote_fetchers as $rf ) {
6193 if ( '' === $rf ) {
6194 continue;
6195 }
6196 $rf_regex = '/(?<![a-z0-9_])' . preg_quote( self::lower_ascii( $rf ), '/' ) . '\s*+\(/';
6197 if ( $this->pattern_near( $lower, $us_regex, $rf_regex, 600 ) ) {
6198 /* translators: 1: Function that downloads, 2: Function that unserializes */
6199 return sprintf( __( 'data downloaded from another server is unserialized (%1$s and %2$s)', 'vigilante' ), $rf, $us );
6200 }
6201 }
6202
6203 // file_get_contents() is treated separately from the fetchers
6204 // above: those are unambiguously remote, while file_get_contents
6205 // is PHP's most common LOCAL file reader, and reading a local
6206 // path right next to unserialize() is a legitimate pattern
6207 // (settings import/export, PSR-6 file caches shipped in premium
6208 // plugins, which have no wp.org checksums so this heuristic is
6209 // their only filter). It only acts as a remote fetcher when its
6210 // argument is a URL, so the combo additionally requires a
6211 // remote-scheme literal near the call before it fires.
6212 $fg = $fragments['fg'] ?? '';
6213 if ( '' !== $fg ) {
6214 $fg_regex = '/(?<![a-z0-9_])' . preg_quote( self::lower_ascii( $fg ), '/' ) . '\s*+\(/';
6215 $scheme_regex = '/(?:https?|ftps?):\/\/|php:\/\/input/';
6216
6217 if ( $this->pattern_near( $lower, $us_regex, $fg_regex, 600 )
6218 && $this->pattern_near( $lower, $fg_regex, $scheme_regex, 600 ) ) {
6219 /* translators: 1: Function that downloads, 2: Function that unserializes */
6220 return sprintf( __( 'data downloaded from another server is unserialized (%1$s and %2$s)', 'vigilante' ), $fg, $us );
6221 }
6222 }
6223 }
6224
6225 $common = $this->detect_common_pattern( $content );
6226 if ( $common ) {
6227 return $common;
6228 }
6229
6230 // Dangerous function names built from string concatenation: a variable
6231 // given a name in pieces and then called. The assignments are taken
6232 // one at a time and the calls are looked up in one pass. Collecting
6233 // every assignment of the file first, and then searching the whole
6234 // file again for each one, took memory and time that grew with the
6235 // square of the file.
6236 $split = array();
6237 $capped = false;
6238 $offset = 0;
6239
6240 while ( true ) {
6241 // A variable, "=" and a quoted piece followed by a dot: where a
6242 // string put together from pieces starts.
6243 $start = $this->next_match( '/\$([a-z_]\w*)\s*+=\s*+(?=["\'][a-z0-9_]*+["\']\s*+\.)/i', $content, $offset );
6244
6245 if ( null === $start ) {
6246 break;
6247 }
6248
6249 // The pieces are taken one by one, however many there are: a name
6250 // can be padded with as many empty ones as its author likes, and
6251 // asking PCRE for the whole chain at once is what made it give up.
6252 $position = $start[0][1] + strlen( $start[0][0] );
6253 $combined = '';
6254 $closed = false;
6255
6256 while ( true ) {
6257 $piece = $this->next_match( '/\G["\']([a-z0-9_]*+)["\']\s*+(\.\s*+|;)?+/i', $content, $position );
6258
6259 if ( null === $piece ) {
6260 break;
6261 }
6262
6263 // No name on the list is this long: past it, the letters are
6264 // not kept, and the chain is still walked to its end.
6265 if ( strlen( $combined ) <= 64 ) {
6266 $combined .= self::lower_ascii( $piece[1][0] );
6267 }
6268
6269 $position = $piece[0][1] + strlen( $piece[0][0] );
6270 $after = isset( $piece[2] ) && $piece[2][1] >= 0 ? $piece[2][0] : '';
6271
6272 if ( '' === $after || ';' === $after ) {
6273 $closed = ';' === $after;
6274 break;
6275 }
6276 }
6277
6278 $offset = max( $position, $start[0][1] + 1 );
6279
6280 if ( $closed && in_array( $combined, $dangerous_names, true ) && ! isset( $split[ $start[1][0] ] ) ) {
6281 $split[ $start[1][0] ] = $combined;
6282
6283 if ( count( $split ) >= 500 ) {
6284 $capped = true;
6285 break;
6286 }
6287 }
6288 }
6289
6290 if ( ! empty( $split ) ) {
6291 $offset = 0;
6292
6293 while ( true ) {
6294 $call = $this->next_match( '/\$([a-z_]\w*)\s*+\(/i', $content, $offset );
6295
6296 if ( null === $call ) {
6297 break;
6298 }
6299
6300 if ( isset( $split[ $call[1][0] ] ) ) {
6301 /* translators: %s: Function name */
6302 return sprintf( __( 'call to %s() with its name split in pieces', 'vigilante' ), $split[ $call[1][0] ] );
6303 }
6304
6305 $offset = $call[0][1] + strlen( $call[0][0] );
6306 }
6307
6308 // More names in pieces than a file has any reason to hold, and
6309 // not every one was followed: not searched, rather than clean.
6310 if ( $capped ) {
6311 $this->search_failed = true;
6312 }
6313 }
6314
6315 return false;
6316 }
6317
6318 /**
6319 * Whether a scan needle is present as a real token rather than glued inside
6320 * a longer identifier.
6321 *
6322 * Function-call needles (an identifier followed by "(") are matched with a
6323 * left word boundary, so "file_get_contents(" no longer matches
6324 * "wpcom_vip_file_get_contents(", "unserialize(" no longer matches
6325 * "maybe_unserialize(", and "eval(" no longer matches "retrieval(". Needles
6326 * that are not plain identifiers (such as the "$_GET[" superglobal probes)
6327 * keep a plain case-insensitive substring search.
6328 *
6329 * @param string $content File content.
6330 * @param string $needle Decoded needle (e.g. "eval(", "$_GET[").
6331 * @return bool
6332 */
6333 private function needle_present( $content, $needle ) {
6334 if ( '' === $needle ) {
6335 return false;
6336 }
6337 if ( preg_match( '/^[a-z_][a-z0-9_]*\($/i', $needle ) ) {
6338 $fn = rtrim( $needle, '(' );
6339 return $this->found( '/(?<![a-z0-9_])' . preg_quote( $fn, '/' ) . '\s*+\(/i', $content );
6340 }
6341 return stripos( $content, $needle ) !== false;
6342 }
6343
6344 /**
6345 * Whether two patterns both occur within $window bytes of each other.
6346 *
6347 * Used to require that correlated malware signals (for example a remote
6348 * fetch and an unserialize call) sit in the same code path instead of
6349 * merely coexisting somewhere in the file, which was a false-positive
6350 * source when only their presence was checked.
6351 *
6352 * @param string $content File content.
6353 * @param string $regex_a First anchored pattern (with delimiters and flags).
6354 * @param string $regex_b Second anchored pattern (with delimiters and flags).
6355 * @param int $window Maximum byte distance between a match of each.
6356 * @return bool
6357 */
6358 private function pattern_near( $content, $regex_a, $regex_b, $window ) {
6359 // Both lists come in order, so they are walked together: the match
6360 // that is behind moves on, and no pair is compared twice. Every match
6361 // of one used to be compared with every match of the other.
6362 $a = $this->next_match( $regex_a, $content, 0 );
6363 $b = $this->next_match( $regex_b, $content, 0 );
6364
6365 while ( null !== $a && null !== $b ) {
6366 if ( abs( $a[0][1] - $b[0][1] ) <= $window ) {
6367 return true;
6368 }
6369
6370 if ( $a[0][1] < $b[0][1] ) {
6371 $a = $this->next_match( $regex_a, $content, $a[0][1] + 1 );
6372 } else {
6373 $b = $this->next_match( $regex_b, $content, $b[0][1] + 1 );
6374 }
6375 }
6376
6377 return false;
6378 }
6379
6380 /**
6381 * A text with its capital letters from A to Z in lower case, and nothing else
6382 *
6383 * strtolower() follows the language of the server until PHP 8.2, and in
6384 * Turkish a capital I does not become an i: a function name written in
6385 * capitals would not be found there.
6386 *
6387 * @param string $text Text.
6388 * @return string
6389 */
6390 private static function lower_ascii( $text ) {
6391 return strtr( (string) $text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz' );
6392 }
6393
6394 /**
6395 * Whether an expression matches, without taking a search that gave up for a "no"
6396 *
6397 * preg_match() answers false, and not 0, when PCRE stops before the end
6398 * because a long run used up the stack of its compiler or its
6399 * backtracking limit. Read as "nothing found", that is a file that passes
6400 * as clean without having been searched. Measured on what was published
6401 * until now: a hexadecimal string of some 12,000 characters made the
6402 * expression that looks for hexadecimal strings give up. Every expression
6403 * of the search goes through here or through next_match(), and a file on
6404 * which one of them gave up is reported as not searched (search_file()).
6405 *
6406 * @param string $regex Expression, with delimiters and flags.
6407 * @param string $content Text to search.
6408 * @return bool
6409 */
6410 private function found( $regex, $content ) {
6411 $result = preg_match( $regex, $content );
6412
6413 if ( false === $result ) {
6414 $this->search_failed = true;
6415 }
6416
6417 return 1 === $result;
6418 }
6419
6420 /**
6421 * The next match of an expression from a position on
6422 *
6423 * For the checks that walk every match. Asked for one at a time they take
6424 * no memory to speak of, where preg_match_all() held every match of the
6425 * file at once.
6426 *
6427 * @param string $regex Expression, with delimiters and flags.
6428 * @param string $content Text to search.
6429 * @param int $offset Where to start.
6430 * @return array|null The match as PREG_OFFSET_CAPTURE gives it, or null.
6431 */
6432 private function next_match( $regex, $content, $offset ) {
6433 $match = array();
6434 $result = preg_match( $regex, $content, $match, PREG_OFFSET_CAPTURE, $offset );
6435
6436 if ( false === $result ) {
6437 $this->search_failed = true;
6438 }
6439
6440 return 1 === $result ? $match : null;
6441 }
6442
6443 /**
6444 * Whether an expression matches in the code of a file and not only in its comments
6445 *
6446 * @param string $regex Expression, with delimiters and flags.
6447 * @param string $content File content.
6448 * @param string|false|null $code The content with its comments blanked. Worked
6449 * out here the first time it is needed and kept
6450 * by the caller; false when it cannot be.
6451 * @return bool
6452 */
6453 private function found_in_code( $regex, $content, &$code ) {
6454 if ( ! $this->found( $regex, $content ) ) {
6455 return false;
6456 }
6457
6458 if ( null === $code ) {
6459 $code = $this->blank_comments( $content );
6460 }
6461
6462 // When PHP cannot be asked what is a comment, the match stands.
6463 return false === $code || $this->found( $regex, $code );
6464 }
6465
6466 /**
6467 * The content of a PHP file with its comments turned into blank space
6468 *
6469 * PHP itself says what is a comment (token_get_all()), so nothing that
6470 * runs can be taken for one: a "//" comment ends at "?>", a string that
6471 * holds "/*" opens nothing, and so on, without a second reader of the
6472 * language here to get any of it wrong. Each comment is replaced by as
6473 * many spaces as it had characters, line breaks kept, so the result is as
6474 * long as the original and what was on one line stays on one line.
6475 *
6476 * Three cases are left as they are, and then the match stands:
6477 *
6478 * - A file with a "<?" that is not "<?php" or "<?=". Whether PHP reads
6479 * that as an opening tag depends on short_open_tag, which may not be
6480 * set the same for the PHP that runs this scan as for the one that
6481 * serves the site, and with it changes where the code starts and so
6482 * what is a comment.
6483 * - A comment that starts with "#[". PHP 8 reads that as an attribute and
6484 * runs what follows it on the line; PHP 7 reads the whole line as a
6485 * comment.
6486 * - A file whose tokens would not fit in the memory that is left, or
6487 * would take more than 64 MB. PHP returns one array per token, up to
6488 * 450 bytes each, and a file can be written so that every byte is one:
6489 * 400 KB of that took 180 MB with PHP 7.4 (measured). How many tokens
6490 * there can be is counted first, without making them.
6491 * - Any file, when PHP is set to read its source in another encoding
6492 * (zend.multibyte). In Shift JIS, Big5 or GBK the second byte of a
6493 * character can be a backslash, so where a string ends is not where
6494 * it ends for a reader that goes byte by byte.
6495 *
6496 * @param string $content File content.
6497 * @return string|false The content without its comments, or false when it
6498 * cannot be told what is a comment.
6499 */
6500 private function blank_comments( $content ) {
6501 if ( ! function_exists( 'token_get_all' ) || ini_get( 'zend.multibyte' ) || preg_match( '/<\?(?!php[ \t\r\n]|=)/i', $content ) ) {
6502 return false;
6503 }
6504
6505 // No more tokens than this: each character that is not part of a
6506 // word can be one, and so can each run of digits and each word.
6507 $words = 0;
6508 $others = preg_replace( '/[a-z_\x80-\xff][a-z0-9_\x80-\xff]*+|[0-9]++/i', '', $content, -1, $words );
6509
6510 if ( null === $others ) {
6511 return false;
6512 }
6513
6514 $needed = 450 * ( strlen( $others ) + $words + 2 ) + strlen( $content );
6515 $limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) );
6516 unset( $others );
6517
6518 if ( $needed > 64 * MB_IN_BYTES || ( $limit > 0 && ( $limit - memory_get_usage() ) < $needed ) ) {
6519 return false;
6520 }
6521
6522 $tokens = @token_get_all( $content ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- PHP 7 warns about what it cannot read in the file being scanned, such as a comment left open; the warning says nothing about this code.
6523 $code = '';
6524 $length = 0;
6525
6526 foreach ( $tokens as $token ) {
6527 $text = is_array( $token ) ? $token[1] : $token;
6528 $length += strlen( $text );
6529
6530 if ( is_array( $token ) && ( T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0] ) && 0 !== strpos( $text, '#[' ) ) {
6531 $text = preg_replace( '/[^\n]/', ' ', $text );
6532 }
6533
6534 $code .= $text;
6535 }
6536
6537 // The pieces have to add up to the file. If they do not, the
6538 // positions cannot be trusted and neither can the result.
6539 if ( strlen( $content ) !== $length || strlen( $code ) !== $length ) {
6540 return false;
6541 }
6542
6543 return $code;
6544 }
6545
6546 /**
6547 * Search one file for hidden code
6548 *
6549 * The one place where a file is read for that. Each scan used to read it
6550 * by itself, and only when it was under 512,000 bytes: a larger file was
6551 * not opened, and where the file was not going to be listed anyway (a
6552 * plugin or a theme with no official copy) that was the same as calling
6553 * it clean. Padding a file was enough to keep it out of the results.
6554 *
6555 * @param string $file_path Absolute path.
6556 * @param bool $strict Use strict mode (see detect_suspicious_pattern()).
6557 * @return string|false|null The pattern found, false when the file was
6558 * searched and has none, or null when it could
6559 * not be searched: it is larger than
6560 * MAX_SEARCHED_BYTES, it cannot be read, or an
6561 * expression gave up on it. The caller reports
6562 * that instead of taking it for clean.
6563 */
6564 private function search_file( $file_path, $strict = false ) {
6565 if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) {
6566 return null;
6567 }
6568
6569 $size = filesize( $file_path );
6570
6571 if ( false === $size || $size > self::MAX_SEARCHED_BYTES ) {
6572 return null;
6573 }
6574
6575 // One byte past the cap and no more, whatever the size said: a file
6576 // that grew since it was measured is not read whole.
6577 $content = file_get_contents( $file_path, false, null, 0, self::MAX_SEARCHED_BYTES + 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to look for hidden code; nothing is written.
6578
6579 if ( false === $content || strlen( $content ) > self::MAX_SEARCHED_BYTES ) {
6580 return null;
6581 }
6582
6583 $this->search_failed = false;
6584 $pattern = $this->detect_suspicious_pattern( $content, $strict );
6585
6586 if ( $pattern ) {
6587 return $pattern;
6588 }
6589
6590 return $this->search_failed ? null : false;
6591 }
6592
6593 /**
6594 * The finding for a PHP file with no official copy that could not be searched
6595 *
6596 * @param string $file_path Absolute path.
6597 * @param string $relative Path for display.
6598 * @return array
6599 */
6600 private function not_searched_item( $file_path, $relative ) {
6601 $size = is_file( $file_path ) ? filesize( $file_path ) : false;
6602 $reason = __( 'PHP file that could not be searched for hidden code', 'vigilante' );
6603
6604 if ( false !== $size && $size > self::MAX_SEARCHED_BYTES ) {
6605 /* translators: %s: File size, such as 12 MB */
6606 $reason = sprintf( __( 'PHP file of %s, too large to be searched for hidden code', 'vigilante' ), size_format( $size ) );
6607 }
6608
6609 return array(
6610 'file' => $relative,
6611 'type' => 'not_searched',
6612 'reason' => $reason,
6613 );
6614 }
6615
6616 /**
6617 * Whether a file is a text file worth normalizing before the fallback hash
6618 * comparison in hash_matches_published().
6619 *
6620 * @param string $file_path Absolute path.
6621 * @return bool
6622 */
6623 private function is_text_file( $file_path ) {
6624 $text_ext = array(
6625 'php', 'php3', 'php4', 'php5', 'php7', 'phtml',
6626 'js', 'css', 'html', 'htm', 'xml', 'svg',
6627 'txt', 'md', 'json', 'po', 'pot', 'yml', 'yaml', 'ini', 'csv',
6628 );
6629 return in_array( strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ), $text_ext, true );
6630 }
6631
6632 /**
6633 * Normalize a relative path for checksum-key comparison: forward slashes,
6634 * no doubled slashes, no leading "./" or "/". Case is preserved because
6635 * plugin and theme file systems are case-sensitive on most hosts.
6636 *
6637 * @param string $path Relative path.
6638 * @return string
6639 */
6640 private function normalize_rel_path( $path ) {
6641 $path = str_replace( '\\', '/', $path );
6642 $path = preg_replace( '#/+#', '/', $path );
6643 if ( 0 === strpos( $path, './' ) ) {
6644 $path = substr( $path, 2 );
6645 }
6646 return ltrim( $path, '/' );
6647 }
6648
6649 /**
6650 * Load scan patterns from external JSON file
6651 *
6652 * Patterns are stored in a JSON file with base64-encoded values
6653 * to prevent hosting WAF/antimalware from flagging the scanner
6654 * PHP file as suspicious.
6655 *
6656 * @return array Patterns data.
6657 */
6658 private function load_scan_patterns() {
6659 static $cached = null;
6660
6661 if ( null !== $cached ) {
6662 return $cached;
6663 }
6664
6665 $file = VIGILANTE_INCLUDES_DIR . 'scan-patterns.json';
6666
6667 if ( ! file_exists( $file ) ) {
6668 $cached = array();
6669 return $cached;
6670 }
6671
6672 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read, not remote.
6673 $json = file_get_contents( $file );
6674 $cached = json_decode( $json, true );
6675
6676 if ( ! is_array( $cached ) ) {
6677 $cached = array();
6678 }
6679
6680 return $cached;
6681 }
6682
6683 /**
6684 * Detect extra files in a directory that are not in checksums
6685 * (potential backdoors injected into plugins/themes)
6686 *
6687 * When $suspicious_only is true (no checksums available), only files
6688 * with suspicious code patterns are reported. This avoids flooding
6689 * results with every PHP file from plugins/themes not on WordPress.org.
6690 *
6691 * @param string $directory Directory to scan.
6692 * @param array $checksums Known checksums from WordPress.org (empty if unavailable).
6693 * @param string $type 'plugin' or 'theme'.
6694 * @param string $name Plugin or theme name.
6695 * @param bool $suspicious_only Only report files with suspicious patterns.
6696 * @return array Array with 'suspicious' and 'extra' sub-arrays.
6697 */
6698 private function detect_extra_files( $directory, $checksums, $type, $name, $suspicious_only = false ) {
6699 $found = array(
6700 'suspicious' => array(),
6701 'extra' => array(),
6702 'errors' => array(),
6703 'php_files' => 0,
6704 );
6705 // Every PHP file is examined. Until now the walk stopped after 50, which
6706 // in a plugin or theme with no official copy meant the first 50 files
6707 // of the folder and nothing else. Only the list of plain extra files is
6708 // bounded, so a folder full of generated PHP cannot fill the results;
6709 // hidden code is reported wherever it is.
6710 $max_listed = 500;
6711 $listed = 0;
6712 $truncated = false;
6713
6714 // Only check PHP files for performance
6715 $php_extensions = self::php_extensions();
6716
6717 // Pre-normalize the checksum keys once so path-shape differences
6718 // (Windows backslashes, a leading "./", doubled slashes) don't make a
6719 // known file look "extra" and get scanned or flagged. Case is preserved.
6720 $known_normalized = array();
6721 foreach ( array_keys( $checksums ) as $known_file ) {
6722 $known_normalized[ $this->normalize_rel_path( $known_file ) ] = true;
6723 }
6724
6725 try {
6726 foreach ( self::walk_folder( $directory ) as $file ) {
6727 if ( $this->is_time_exceeded() ) {
6728 break;
6729 }
6730
6731 $file_path = $file->getPathname();
6732
6733 if ( $file->isDir() ) {
6734 if ( ! $file->isReadable() ) {
6735 $found['errors'][] = self::unreadable_folder_error( $file_path );
6736 }
6737 continue;
6738 }
6739
6740 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
6741
6742 // A .htaccess, .user.ini or php.ini that is not part of the
6743 // published copy is looked at for what it makes the server do.
6744 if ( in_array( strtolower( basename( $file_path ) ), array( '.htaccess', '.user.ini', 'php.ini' ), true ) ) {
6745 $config_path = $this->normalize_rel_path( str_replace( str_replace( '\\', '/', $directory ) . '/', '', str_replace( '\\', '/', $file_path ) ) );
6746
6747 if ( ! isset( $known_normalized[ $config_path ] ) && ! $this->is_path_excluded( $file_path ) ) {
6748 $config = self::server_config_finding( $file_path, str_replace( ABSPATH, '', $file_path ) );
6749
6750 if ( $config ) {
6751 $found[ $config['category'] ][] = $config['item'];
6752 }
6753 }
6754 continue;
6755 }
6756
6757 // Only check PHP files
6758 if ( ! in_array( $extension, $php_extensions, true ) ) {
6759 continue;
6760 }
6761
6762 // Get relative path within plugin/theme directory, normalized so
6763 // path-shape quirks don't misclassify a known file as "extra".
6764 $norm_path = str_replace( '\\', '/', $file_path );
6765 $norm_dir = str_replace( '\\', '/', $directory );
6766 $relative_to_dir = $this->normalize_rel_path( str_replace( $norm_dir . '/', '', $norm_path ) );
6767
6768 // Skip if file is in the checksums (it's known)
6769 if ( isset( $known_normalized[ $relative_to_dir ] ) ) {
6770 continue;
6771 }
6772
6773 // Skip excluded paths
6774 if ( $this->is_path_excluded( $file_path ) ) {
6775 continue;
6776 }
6777
6778 // Skip "Silence is golden" placeholder index.php files used by
6779 // WordPress core and many plugins to prevent directory listings.
6780 // The check is content-based — an attacker cannot bypass it by
6781 // simply naming a payload file index.php.
6782 if ( $this->is_silence_golden_file( $file_path ) ) {
6783 continue;
6784 }
6785
6786 $found['php_files']++;
6787
6788 $relative = str_replace( ABSPATH, '', $file_path );
6789
6790 // Check for suspicious content in extra files
6791 // Use strict mode for plugins without checksums to avoid false positives
6792 $pattern = $this->search_file( $file_path, $suspicious_only );
6793
6794 $item = array(
6795 'file' => $relative,
6796 $type => $name,
6797 );
6798
6799 if ( $pattern ) {
6800 // Suspicious content: promote to suspicious category
6801 $item['type'] = 'suspicious_' . $type;
6802
6803 // With an official copy to compare with, the file is known
6804 // not to belong to it. Without one it may well be the
6805 // plugin's own, so it is not called injected.
6806 if ( $suspicious_only ) {
6807 /* translators: 1: Plugin or theme name, or the folder the file is in, 2: Suspicious pattern found */
6808 $item['reason'] = sprintf( __( 'File in %1$s with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
6809 } else {
6810 /* translators: 1: Plugin or theme name, 2: Suspicious pattern found */
6811 $item['reason'] = sprintf( __( 'File that is not part of %1$s, with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
6812 }
6813
6814 $found['suspicious'][] = $item;
6815 } elseif ( ! $suspicious_only ) {
6816 // No suspicious patterns and checksums available: report as extra
6817 // Skipped in suspicious_only mode (no checksums) to avoid noise
6818 if ( $listed >= $max_listed ) {
6819 if ( ! $truncated ) {
6820 $truncated = true;
6821 $found['errors'][] = sprintf(
6822 /* translators: 1: Plugin or theme name, 2: Number of files */
6823 __( '%1$s has more extra files than can be listed: only the first %2$d are shown.', 'vigilante' ),
6824 $name,
6825 $max_listed
6826 );
6827 }
6828 continue;
6829 }
6830
6831 $listed++;
6832 $item['type'] = 'extra_' . $type;
6833 $item['reason'] = __( 'PHP file not present in original distribution', 'vigilante' );
6834 $found['extra'][] = $item;
6835 } elseif ( null === $pattern ) {
6836 // No official copy to compare with, and the search could
6837 // not look inside either: said, not passed over.
6838 $found['extra'][] = $this->not_searched_item( $file_path, $relative );
6839 }
6840 }
6841 } catch ( Exception $e ) {
6842 $found['errors'][] = self::unreadable_folder_error( $directory );
6843 }
6844
6845 return $found;
6846 }
6847
6848 /**
6849 * Whether the given file is a trivial "Silence is golden" placeholder.
6850 *
6851 * WordPress core and most plugins drop an empty or near-empty index.php
6852 * inside their directories to block directory listings on misconfigured
6853 * servers. Those files trip the extra/suspicious detector even though
6854 * they're harmless. We whitelist them by content (not by name) so an
6855 * attacker cannot bypass the rule simply by calling a payload index.php.
6856 *
6857 * @param string $file_path Absolute path to the file being scanned.
6858 * @return bool True when the file is a known harmless placeholder.
6859 */
6860 private function is_silence_golden_file( $file_path ) {
6861 if ( 'index.php' !== basename( $file_path ) ) {
6862 return false;
6863 }
6864
6865 // Cap to avoid reading large files just to check this. Real placeholders
6866 // are always tiny (< 100 bytes); anything bigger isn't one.
6867 $size = @filesize( $file_path );
6868 if ( false === $size || $size > 256 ) {
6869 return false;
6870 }
6871
6872 $content = @file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
6873 if ( false === $content ) {
6874 return false;
6875 }
6876
6877 // Every run of whitespace counts as one space, so the placeholder
6878 // WordPress itself ships (the opening tag, a line break and the comment)
6879 // matches the same entries as the one-line form. The comparison is
6880 // still against the whole file: nothing can follow these few words, and
6881 // no arrangement of them on several lines is code that runs.
6882 $normalized = strtolower( trim( preg_replace( '/\s+/', ' ', $content ) ) );
6883
6884 $known = array(
6885 '',
6886 '<?php',
6887 '<?php //silence is golden.',
6888 '<?php // silence is golden.',
6889 '<?php //silence is golden',
6890 '<?php // silence is golden',
6891 );
6892
6893 return in_array( $normalized, $known, true );
6894 }
6895
6896 /**
6897 * Folders of the installation that hold plugins or themes
6898 *
6899 * @return array Relative path => true, for the plugins folder and every
6900 * theme root that lies inside the WordPress directory.
6901 */
6902 private function code_roots() {
6903 global $wp_theme_directories;
6904
6905 $roots = array();
6906
6907 foreach ( array_merge( array( WP_PLUGIN_DIR, get_theme_root() ), is_array( $wp_theme_directories ) ? $wp_theme_directories : array() ) as $root ) {
6908 $root = untrailingslashit( str_replace( '\\', '/', (string) $root ) );
6909
6910 if ( '' !== $root && 0 === strpos( $root . '/', str_replace( '\\', '/', ABSPATH ) ) ) {
6911 $roots[ $this->relative_path( $root ) ] = true;
6912 }
6913 }
6914
6915 return $roots;
6916 }
6917
6918 /**
6919 * What a partial scan offers to exclude, so that the next one reaches the rest
6920 *
6921 * The folders of the plugins and themes the scan did check, as lines for
6922 * Excluded Paths, less the ones that are excluded by now. A scan starts
6923 * from the beginning every time, so with these out of the way a scan
6924 * run by hand spends its time on what the last one never got to. They
6925 * have to come out of the list afterwards, and the tab says so for as
6926 * long as they are there (see excluded_code_folders()).
6927 *
6928 * The list comes from stored results, and whoever reads it is told to
6929 * paste it into the exclusions, so only what can be the folder of a
6930 * plugin or of a theme is let through: one line with anything else in it
6931 * would exclude far more than it says.
6932 *
6933 * @param mixed $results Scan results, as run_scan() returns them.
6934 * @return array Relative paths, one per folder.
6935 */
6936 public function folders_to_exclude( $results ) {
6937 $stored = is_array( $results ) && isset( $results['checked_folders'] ) && is_array( $results['checked_folders'] ) ? $results['checked_folders'] : array();
6938 $roots = $this->code_roots();
6939 $lines = array();
6940
6941 foreach ( $stored as $folder ) {
6942 if ( ! is_string( $folder ) || 1 === preg_match( '/[\\x00-\\x1f\\x7f]/', $folder ) ) {
6943 continue;
6944 }
6945
6946 $folder = trim( str_replace( '\\', '/', $folder ), '/' );
6947 $name = basename( $folder );
6948
6949 if ( '' === $name || '.' === $name || '..' === $name || ! isset( $roots[ dirname( $folder ) ] ) ) {
6950 continue;
6951 }
6952
6953 if ( ! is_dir( ABSPATH . $folder ) || $this->is_path_excluded( ABSPATH . $folder . '/.' ) ) {
6954 continue;
6955 }
6956
6957 $lines[ $folder ] = true;
6958 }
6959
6960 return array_keys( $lines );
6961 }
6962
6963 /**
6964 * Whole folders of code, or the uploads folder, that Excluded Paths keeps out of every scan
6965 *
6966 * A subfolder of uploads or of a plugin is an ordinary exclusion (a cache
6967 * of compiled templates, say). A whole plugin, a whole theme, the
6968 * must-use plugins or all of uploads is something else: the scan comes
6969 * out complete and clean without having looked there. The tab and
6970 * Security Check go by this list, so that nobody is left thinking those
6971 * folders are being watched.
6972 *
6973 * @return array Relative paths. A folder that holds others (the plugins
6974 * folder, a theme root) stands for all of them.
6975 */
6976 public function excluded_code_folders() {
6977 if ( empty( $this->options['excluded_paths'] ) || ! is_array( $this->options['excluded_paths'] ) ) {
6978 return array();
6979 }
6980
6981 if ( ! function_exists( 'get_plugins' ) ) {
6982 require_once ABSPATH . 'wp-admin/includes/plugin.php';
6983 }
6984
6985 $found = array();
6986 $roots = array_keys( $this->code_roots() );
6987
6988 foreach ( $roots as $root ) {
6989 if ( $this->is_path_excluded( ABSPATH . $root . '/.' ) ) {
6990 $found[ $root ] = true;
6991 }
6992 }
6993
6994 $plugins_root = $this->relative_path( WP_PLUGIN_DIR );
6995
6996 if ( ! isset( $found[ $plugins_root ] ) ) {
6997 foreach ( array_keys( get_plugins() ) as $plugin_file ) {
6998 $slug = dirname( $plugin_file );
6999
7000 if ( '.' !== $slug && $this->is_path_excluded( WP_PLUGIN_DIR . '/' . $slug . '/.' ) ) {
7001 $found[ $this->relative_path( WP_PLUGIN_DIR . '/' . $slug ) ] = true;
7002 }
7003 }
7004 }
7005
7006 foreach ( wp_get_themes() as $theme ) {
7007 $dir = $theme->get_stylesheet_directory();
7008
7009 if ( ! isset( $found[ $this->relative_path( dirname( $dir ) ) ] ) && $this->is_path_excluded( $dir . '/.' ) ) {
7010 $found[ $this->relative_path( $dir ) ] = true;
7011 }
7012 }
7013
7014 if ( defined( 'WPMU_PLUGIN_DIR' ) && is_dir( WPMU_PLUGIN_DIR ) && $this->is_path_excluded( WPMU_PLUGIN_DIR . '/.' ) ) {
7015 $found[ $this->relative_path( WPMU_PLUGIN_DIR ) ] = true;
7016 }
7017
7018 $uploads = wp_get_upload_dir();
7019
7020 if ( ! empty( $uploads['basedir'] ) && $this->is_path_excluded( $uploads['basedir'] . '/.' ) ) {
7021 $found[ $this->relative_path( $uploads['basedir'] ) ] = true;
7022 }
7023
7024 return array_keys( $found );
7025 }
7026
7027 /**
7028 * Whether a scan checked less than it was asked to
7029 *
7030 * Either it ran out of time, or something could not be read or compared
7031 * (a folder, a plugin whose copy on WordPress.org could not be fetched).
7032 * The tab, the scan email and Security Check go by this, so none of them
7033 * calls clean a result that covers only part of the site.
7034 *
7035 * @param mixed $results Scan results, as run_scan() returns them.
7036 * @return bool
7037 */
7038 public static function is_partial( $results ) {
7039 return is_array( $results ) && ( ! empty( $results['incomplete'] ) || ! empty( $results['errors'] ) );
7040 }
7041
7042 /**
7043 * What a scan did not get to, one line per thing
7044 *
7045 * The one source of that text, for the tab, the scan email and anything
7046 * else that shows the result of a scan. In the order of the scan.
7047 *
7048 * @param mixed $results Scan results, as run_scan() returns them.
7049 * @return array Lines ready to show, not escaped. Empty when nothing was
7050 * left out.
7051 */
7052 public static function not_checked_lines( $results ) {
7053 $left = is_array( $results ) && isset( $results['not_checked'] ) && is_array( $results['not_checked'] ) ? $results['not_checked'] : array();
7054
7055 $areas = isset( $left['areas'] ) && is_array( $left['areas'] ) ? $left['areas'] : array();
7056 $plugins = isset( $left['plugins'] ) && is_array( $left['plugins'] ) ? array_filter( array_map( 'strval', $left['plugins'] ), 'strlen' ) : array();
7057 $themes = isset( $left['themes'] ) && is_array( $left['themes'] ) ? array_filter( array_map( 'strval', $left['themes'] ), 'strlen' ) : array();
7058 $lines = array();
7059
7060 if ( in_array( 'uploads', $areas, true ) ) {
7061 $lines[] = __( 'The uploads folder was not checked.', 'vigilante' );
7062 }
7063
7064 if ( in_array( 'core', $areas, true ) ) {
7065 $lines[] = __( 'The WordPress files were not checked completely.', 'vigilante' );
7066 }
7067
7068 if ( in_array( 'config', $areas, true ) ) {
7069 $lines[] = __( 'wp-config.php and .htaccess were not compared.', 'vigilante' );
7070 }
7071
7072 if ( in_array( 'content', $areas, true ) ) {
7073 $lines[] = __( 'The other folders of wp-content were not checked.', 'vigilante' );
7074 }
7075
7076 if ( in_array( 'plugins', $areas, true ) ) {
7077 $lines[] = __( 'No plugin was checked.', 'vigilante' );
7078 } elseif ( ! empty( $plugins ) ) {
7079 /* translators: %s: Comma separated list of plugin names */
7080 $lines[] = sprintf( __( 'Plugins not checked: %s.', 'vigilante' ), implode( ', ', $plugins ) );
7081 }
7082
7083 if ( in_array( 'unlisted', $areas, true ) ) {
7084 $lines[] = __( 'Must-use plugins, drop-ins and loose PHP files were not checked.', 'vigilante' );
7085 }
7086
7087 if ( in_array( 'themes', $areas, true ) ) {
7088 $lines[] = __( 'No theme was checked.', 'vigilante' );
7089 } elseif ( ! empty( $themes ) ) {
7090 /* translators: %s: Comma separated list of theme names */
7091 $lines[] = sprintf( __( 'Themes not checked: %s.', 'vigilante' ), implode( ', ', $themes ) );
7092 }
7093
7094 return $lines;
7095 }
7096
7097 /**
7098 * Whether the scheduled scan has not finished for longer than it should
7099 *
7100 * A scan that PHP or the server stops halfway stores nothing, so the date
7101 * of the last one that finished is the only sign left. Twice the interval
7102 * and one day more, because WP-Cron only runs when the site has a visit.
7103 *
7104 * @param int $last_scan Time of the last stored scan, 0 for none.
7105 * @param string $frequency 'daily' or 'weekly'.
7106 * @return bool
7107 */
7108 public static function is_scan_overdue( $last_scan, $frequency ) {
7109 $last_scan = (int) $last_scan;
7110
7111 if ( $last_scan <= 0 ) {
7112 return false;
7113 }
7114
7115 $every = 'weekly' === $frequency ? WEEK_IN_SECONDS : DAY_IN_SECONDS;
7116
7117 return ( time() - $last_scan ) > ( 2 * $every + DAY_IN_SECONDS );
7118 }
7119
7120 /**
7121 * What a kind of finding usually is, and what to do about it
7122 *
7123 * The one source of the text shown next to the findings of a scan, so the
7124 * File Integrity tab and anything else that explains them say the same.
7125 * Vigilant's own files have their own source,
7126 * Vigilante_Self_Integrity_Guidance.
7127 *
7128 * @param string $type Finding type as stored in the scan results.
7129 * @return array {
7130 * @type string $label Name of the kind of finding, for a table cell.
7131 * @type string $advice What it usually is and what to do. Empty for a
7132 * type this method does not know.
7133 * }
7134 */
7135 public static function finding_guidance( $type ) {
7136 switch ( $type ) {
7137 case 'php_in_uploads':
7138 return array(
7139 'label' => __( 'PHP in uploads', 'vigilante' ),
7140 'advice' => __( 'Uploads is meant for media, not for code. It is normal only when a plugin you use writes PHP there, such as compiled templates or cache files: ignore the file, or add its folder to Excluded Paths if new ones keep appearing. If nothing you use explains it, treat it as malware.', 'vigilante' ),
7141 );
7142
7143 case 'double_extension':
7144 return array(
7145 'label' => __( 'Double extension', 'vigilante' ),
7146 'advice' => __( 'A name such as photo.php.jpg is a common way to pass a script off as an image. Delete it unless you know who or what uploaded it.', 'vigilante' ),
7147 );
7148
7149 case 'htaccess_in_uploads':
7150 return array(
7151 'label' => __( '.htaccess in uploads', 'vigilante' ),
7152 'advice' => __( 'A .htaccess here changes how the server treats this folder. One that only denies access is not listed. If it makes files run as code (AddHandler, SetHandler, php_value, Options ExecCGI) and you did not put it there, it is a sign of infection. If it only has rewrite rules, it usually belongs to an image or cache plugin, and it switches off for this folder the rule that blocks PHP in uploads.', 'vigilante' ),
7153 );
7154
7155 case 'php_in_root':
7156 return array(
7157 'label' => __( 'PHP in the site root', 'vigilante' ),
7158 'advice' => __( 'A PHP file next to wp-config.php that WordPress does not ship. Hosting tools, firewalls and verification scripts put files here, and so do attackers. Ignore it if you know where it comes from; if you do not, look at what it contains before anything else.', 'vigilante' ),
7159 );
7160
7161 case 'extra_root':
7162 return array(
7163 'label' => __( 'Extra file in the site root', 'vigilante' ),
7164 'advice' => __( 'WordPress does not ship this file. Verification files and the like are harmless. A copy of wp-config.php, a database dump or a backup archive here can be downloaded by anyone who guesses its name: move it out of the web root.', 'vigilante' ),
7165 );
7166
7167 case 'legacy_core':
7168 return array(
7169 'label' => __( 'Old core file', 'vigilante' ),
7170 'advice' => __( 'Left behind by an earlier version of WordPress. Nothing uses it any more and it can be deleted.', 'vigilante' ),
7171 );
7172
7173 case 'suspicious_plugin':
7174 case 'suspicious_theme':
7175 return array(
7176 'label' => 'suspicious_theme' === $type ? __( 'Hidden code in a theme', 'vigilante' ) : __( 'Hidden code in a plugin', 'vigilante' ),
7177 'advice' => __( 'The file contains code written the way malware hides itself, and it is not part of the published copy, or there is no published copy to compare with. Some commercial plugins and themes obfuscate their own code and end up here. If you cannot tell, replace the plugin or theme with a clean copy from its author.', 'vigilante' ),
7178 );
7179
7180 case 'extra_plugin':
7181 case 'extra_theme':
7182 return array(
7183 'label' => 'extra_theme' === $type ? __( 'Extra file in a theme', 'vigilante' ) : __( 'Extra file in a plugin', 'vigilante' ),
7184 'advice' => __( 'A PHP file that is not in the copy WordPress.org publishes for the installed version. Some plugins and themes create files after they are installed, and a manual fix adds them too. If you cannot explain it, reinstall the plugin or theme: that removes everything that does not belong to it.', 'vigilante' ),
7185 );
7186
7187 case 'php_in_core':
7188 return array(
7189 'label' => __( 'PHP in a WordPress folder', 'vigilante' ),
7190 'advice' => __( 'A PHP file in wp-admin or wp-includes that the installed version of WordPress does not ship. It is either left over from an older version or put there by someone: compare it with a clean copy of WordPress and delete it if it is not in it.', 'vigilante' ),
7191 );
7192
7193 case 'config_in_core':
7194 return array(
7195 'label' => __( 'Configuration file in a WordPress folder', 'vigilante' ),
7196 'advice' => __( 'A .htaccess, .user.ini or php.ini in wp-admin or wp-includes. A .htaccess that asks for a password in wp-admin is a common protection, and some hosts leave a php.ini there. A line with auto_prepend_file or auto_append_file loads a file on every request: make sure you know which one.', 'vigilante' ),
7197 );
7198
7199 case 'php_config_in_uploads':
7200 return array(
7201 'label' => __( 'PHP configuration in uploads', 'vigilante' ),
7202 'advice' => __( 'A .user.ini or php.ini here changes how PHP runs for this folder, and can make it load another file on every request. Nothing in uploads needs one: delete it unless you put it there.', 'vigilante' ),
7203 );
7204
7205 case 'script_in_uploads':
7206 return array(
7207 'label' => __( 'Script in uploads', 'vigilante' ),
7208 'advice' => __( 'A script in another language (shell, Perl, Python, CGI). It only runs if the server is told to run it, which a .htaccess in the same folder can do. Delete it unless you know why it is there.', 'vigilante' ),
7209 );
7210
7211 case 'php_config_in_root':
7212 return array(
7213 'label' => __( 'PHP configuration in the site root', 'vigilante' ),
7214 'advice' => __( 'This file makes PHP load another file before every request. Firewalls such as Wordfence set it up for themselves, and so do some hosts: check that the file it names is theirs. If nothing you installed needs it, that file runs before WordPress on every visit: treat it as a backdoor.', 'vigilante' ),
7215 );
7216
7217 case 'backup_in_root':
7218 return array(
7219 'label' => __( 'Backup in the site root', 'vigilante' ),
7220 'advice' => __( 'A copy of wp-config.php, a database dump or an archive next to WordPress can be downloaded by anyone who guesses its name, and it holds passwords or the whole site. Move it out of the web root or delete it.', 'vigilante' ),
7221 );
7222
7223 case 'php_in_content':
7224 return array(
7225 'label' => __( 'Loose PHP in wp-content', 'vigilante' ),
7226 'advice' => __( 'A PHP file, or a folder with PHP files, in wp-content or among the plugins or the themes, that WordPress does not list as a plugin, a theme or a drop-in. It does not show on the Plugins screen, and it can still be reached by its address. Some plugins keep a settings file there, WP Super Cache for one. If nothing you use explains it, look at what it contains.', 'vigilante' ),
7227 );
7228
7229 case 'suspicious_content':
7230 return array(
7231 'label' => __( 'Hidden code in wp-content', 'vigilante' ),
7232 'advice' => __( 'The file contains code written the way malware hides itself, in a folder of wp-content that belongs to no plugin and no theme. Cache, backup and security plugins keep files in folders like these, and what they store can look like this. Find out which one the folder belongs to. If nothing you use explains the file, treat it as malware.', 'vigilante' ),
7233 );
7234
7235 case 'translation_with_code':
7236 return array(
7237 'label' => __( 'Translation file with code', 'vigilante' ),
7238 'advice' => __( 'WordPress loads the translation files of the site language on every request, and a translation file holds text and nothing else. This one holds something more. Delete it and get the translations again from Dashboard > Updates. If it comes back, something on the site is writing it.', 'vigilante' ),
7239 );
7240
7241 case 'not_searched':
7242 return array(
7243 'label' => __( 'File not searched', 'vigilante' ),
7244 'advice' => __( 'The search for hidden code could not look inside this file, nearly always because it is very large, and there is no official copy to compare it with. A PHP file of that size is unusual. Check that it belongs to the plugin, the theme or the folder it is in, and ignore it if it does.', 'vigilante' ),
7245 );
7246
7247 case 'server_config':
7248 return array(
7249 'label' => __( 'Server configuration file', 'vigilante' ),
7250 'advice' => __( 'A .htaccess, .user.ini or php.ini outside uploads that makes the server run files as code or load a file on every request. Plugins rarely need one. If you cannot tell which one put it there, treat it as a sign of infection.', 'vigilante' ),
7251 );
7252
7253 case 'core':
7254 return array(
7255 'label' => __( 'WordPress core', 'vigilante' ),
7256 'advice' => __( 'Does not match the copy WordPress.org publishes for your version and language. For a file in wp-admin, wp-includes or the site root, reinstall WordPress from Dashboard > Updates and scan again. A file of a bundled theme or of Akismet, under wp-content, appears here after that theme or plugin updates on its own, which is normal.', 'vigilante' ),
7257 );
7258
7259 case 'plugin':
7260 case 'theme':
7261 return array(
7262 'label' => 'theme' === $type ? __( 'Theme', 'vigilante' ) : __( 'Plugin', 'vigilante' ),
7263 'advice' => __( 'Does not match the copy WordPress.org publishes for the installed version. The usual causes are an update that did not finish, a release replaced on WordPress.org without a new version number, or a manual edit. Reinstall it; if the file changes again by itself, investigate.', 'vigilante' ),
7264 );
7265
7266 case 'critical_config':
7267 return array(
7268 'label' => __( 'Configuration file', 'vigilante' ),
7269 'advice' => __( 'Changed since you last approved it. Cache, security and redirection plugins rewrite their own blocks in these files, often on every update, and that shows up here too. Review the changes and approve them if they belong to something you use. Lines you cannot explain, above all the ones that load a file or send requests to one, need a closer look.', 'vigilante' ),
7270 );
7271 }
7272
7273 return array(
7274 'label' => (string) $type,
7275 'advice' => '',
7276 );
7277 }
7278
7279 /**
7280 * Check if a path is excluded from scanning
7281 *
7282 * @param string $path File path.
7283 * @return bool
7284 */
7285 private function is_path_excluded( $path ) {
7286 $excluded = $this->options['excluded_paths'] ?? array();
7287
7288 if ( empty( $excluded ) ) {
7289 return false;
7290 }
7291
7292 $relative = $this->relative_path( $path );
7293
7294 foreach ( $excluded as $exclude ) {
7295 $exclude = trim( trim( str_replace( '\\', '/', (string) $exclude ) ), '/' );
7296
7297 if ( '' === $exclude ) {
7298 continue;
7299 }
7300
7301 /*
7302 * Two forms, both with real boundaries. Until 2.9.9 this was a plain
7303 * strpos() over the relative path, so an exclusion matched anywhere
7304 * inside it: "cache" also silenced a plugin folder named mycache,
7305 * "logs" silenced catalogs, and nothing told the user how much had
7306 * stopped being watched.
7307 *
7308 * Anything with a slash is a path: it excludes exactly that file, or
7309 * everything under it, anchored at the root of the installation.
7310 */
7311 if ( false !== strpos( $exclude, '/' ) ) {
7312 if ( $relative === $exclude || 0 === strpos( $relative, $exclude . '/' ) ) {
7313 return true;
7314 }
7315
7316 continue;
7317 }
7318
7319 /*
7320 * A bare name excludes any folder called exactly that, at any depth,
7321 * which is what someone typing "languages" means. It has to be the
7322 * whole segment, not a fragment of one.
7323 */
7324 if ( $relative === $exclude
7325 || 0 === strpos( $relative, $exclude . '/' )
7326 || false !== strpos( $relative, '/' . $exclude . '/' ) ) {
7327 return true;
7328 }
7329 }
7330
7331 return false;
7332 }
7333
7334 /**
7335 * Path relative to the WordPress directory, with forward slashes
7336 *
7337 * @since 2.9.9
7338 *
7339 * @param string $path Absolute path.
7340 * @return string
7341 */
7342 private function relative_path( $path ) {
7343 $path = str_replace( '\\', '/', (string) $path );
7344 $root = str_replace( '\\', '/', ABSPATH );
7345
7346 if ( 0 === strpos( $path, $root ) ) {
7347 $path = substr( $path, strlen( $root ) );
7348 }
7349
7350 return ltrim( $path, '/' );
7351 }
7352
7353 /**
7354 * Check if a file extension is excluded from scanning
7355 *
7356 * @param string $path File path.
7357 * @return bool
7358 */
7359 private function is_extension_excluded( $path ) {
7360 $excluded = $this->options['excluded_extensions'] ?? array();
7361
7362 if ( empty( $excluded ) ) {
7363 return false;
7364 }
7365
7366 $extension = '.' . strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
7367 $relative = strtolower( $this->relative_path( $path ) );
7368
7369 foreach ( $excluded as $exclude ) {
7370 $exclude = strtolower( trim( (string) $exclude ) );
7371
7372 if ( '' === $exclude ) {
7373 continue;
7374 }
7375
7376 /*
7377 * An extension on its own is global, as it has always been. Since
7378 * 2.9.9 it can also be scoped to a folder, written as
7379 * wp-content/languages/*.json, because the global form is a blunt
7380 * instrument: excluding .json to quiet the translation files also
7381 * stopped watching the 173 block.json files of core.
7382 */
7383 $scope = '';
7384
7385 if ( false !== strpos( $exclude, '*' ) ) {
7386 $parts = explode( '*', $exclude, 2 );
7387 $scope = trim( $parts[0], '/' );
7388 $exclude = $parts[1];
7389 }
7390
7391 if ( '' === $exclude ) {
7392 continue;
7393 }
7394
7395 // Support both ".log" and "log" formats
7396 if ( 0 !== strpos( $exclude, '.' ) ) {
7397 $exclude = '.' . $exclude;
7398 }
7399
7400 if ( $exclude !== $extension ) {
7401 continue;
7402 }
7403
7404 if ( '' === $scope ) {
7405 return true;
7406 }
7407
7408 if ( $relative === $scope || 0 === strpos( $relative, $scope . '/' ) ) {
7409 return true;
7410 }
7411 }
7412
7413 return false;
7414 }
7415
7416 /**
7417 * Filter out ignored files from results
7418 *
7419 * @param array $items Array of scan result items.
7420 * @return array Filtered items.
7421 */
7422 private function filter_ignored( $items ) {
7423 if ( empty( $this->ignored_files ) || empty( $items ) ) {
7424 return $items;
7425 }
7426
7427 return array_values(
7428 array_filter(
7429 $items,
7430 function ( $item ) {
7431 /*
7432 * On a network, wp-config.php and the root .htaccess are not a
7433 * site's to silence: a change to them is closed by approving it,
7434 * and approving takes a network administrator since 2.11.3. The
7435 * ignore list is an option of each site, so until 2.11.8 the
7436 * administrator of the main site without network rights hid a
7437 * pending change from the network administrator's own screen by
7438 * posting the file name to the ignore handler.
7439 */
7440 if ( is_multisite() && is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
7441 return true;
7442 }
7443
7444 // Findings about the manifest and the version of Vigilant
7445 // itself are not about one file, so no entry of the list may
7446 // hide them, on a single site either: ignoring the row of
7447 // MANIFEST.sha256 took a replaced manifest out of the email.
7448 // Vigilante_Self_Integrity::filter_ignored_findings() keeps
7449 // them the same way.
7450 if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) && in_array( $item['self_finding'] ?? '', array( 'manifest_replaced', 'manifest_unverified', 'manifest_missing', 'manifest_invalid', 'self_downgraded' ), true ) ) {
7451 return true;
7452 }
7453 // Nor the findings of the walk of Vigilant's folder (a folder
7454 // that cannot be listed, the folder that could not be walked):
7455 // their path ends in a slash, and ignoring that row left the
7456 // scan with no row and no email while the self-protection
7457 // status stayed critical.
7458 if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) && '/' === substr( (string) ( $item['file'] ?? '' ), -1 ) ) {
7459 return true;
7460 }
7461
7462 $file = is_array( $item ) && isset( $item['file'] ) ? $item['file'] : '';
7463 return ! in_array( $file, $this->ignored_files, true );
7464 }
7465 )
7466 );
7467 }
7468
7469 /**
7470 * Send email notification based on notify_level setting
7471 *
7472 * Supports three levels:
7473 * - 'all': notify on any issues (modified + suspicious + extra)
7474 * - 'suspicious_only': notify only when suspicious or extra files found
7475 * - 'disabled': never send
7476 *
7477 * Backward compatible with old notify_on_changes boolean.
7478 *
7479 * @param array $results Scan results.
7480 */
7481 private function maybe_send_notification( $results ) {
7482 $options = is_array( $this->options ) ? $this->options : array();
7483
7484 // Closed/removed plugins (excluding ignored slugs) travel in this
7485 // email, in the same tier as a suspicious file.
7486 $closed_plugins = $this->collect_closed_plugins_for_email();
7487
7488 /*
7489 * Self-protection is not part of this decision. Its alert is its own
7490 * and no setting switches it off, so this email is about the files of
7491 * the site: core, plugins, themes, uploads and the two shared
7492 * configuration files. Missing files do not send it on their own: it
7493 * has no section to list them in.
7494 */
7495
7496 // Instant alert: every finding travels, modified files included,
7497 // whatever the level below says. A scan with nothing to report goes
7498 // through as well, so that what was reported is forgotten.
7499 if ( ! empty( $options['instant_alert'] ) ) {
7500 $this->send_new_findings( $results, 'all', $closed_plugins );
7501 return;
7502 }
7503
7504 // Determine notify level with backward compatibility
7505 $notify_level = $options['notify_level'] ?? '';
7506
7507 // Backward compat: if notify_level not set, check old boolean
7508 if ( empty( $notify_level ) ) {
7509 if ( ! empty( $options['notify_on_changes'] ) ) {
7510 $notify_level = 'all';
7511 } else {
7512 $notify_level = 'disabled';
7513 }
7514 }
7515
7516 if ( 'disabled' === $notify_level ) {
7517 // Nothing is sent, and nothing stays on record as reported:
7518 // whoever switches the email back on is told about everything
7519 // that is pending then.
7520 delete_option( self::NOTIFIED_OPTION );
7521 return;
7522 }
7523
7524 // 'suspicious_only' treats suspicious/extra, critical_config AND closed
7525 // plugins as serious. A scan with nothing to send at this level still
7526 // goes through send_new_findings(), which then forgets what it had
7527 // reported, so a finding that goes away and comes back is new again.
7528 $this->send_new_findings( $results, $notify_level, $closed_plugins );
7529 }
7530
7531 /**
7532 * What the scan email has already reported to the recipients
7533 */
7534 const NOTIFIED_OPTION = 'vigilante_integrity_notified';
7535
7536 /**
7537 * Send the scan email only when the scan found something new
7538 *
7539 * Until now the email went out after every scan for as long as a finding
7540 * was still listed, so a plugin that rewrote its block in .htaccess meant
7541 * the same alert every day until somebody approved the change. Now every
7542 * finding is reported once: the email goes out when a scan finds something
7543 * the recipients have not been told about, and it lists everything that is
7544 * pending, old and new.
7545 *
7546 * A finding is the file, its kind and its content (by its hash), so a
7547 * file that changes again under the same name is new, also when the
7548 * change keeps its size and its date. What goes away is forgotten, so it
7549 * is new again if it comes back. Nothing is recorded when the email could
7550 * not be sent, and the next scan tries again.
7551 *
7552 * @param array $results Scan results.
7553 * @param string $notify_level 'all' or 'suspicious_only'.
7554 * @param array $closed_plugins Closed plugins, keyed by slug.
7555 */
7556 private function send_new_findings( $results, $notify_level, $closed_plugins ) {
7557 $keys = $this->notification_keys( $results, $closed_plugins, 'all' === $notify_level );
7558 $told = get_option( self::NOTIFIED_OPTION, array() );
7559 $told = is_array( $told ) ? $told : array();
7560
7561 // A partial scan cannot tell whether what it did not reach is still
7562 // there, so it forgets nothing: with the findings of an unchecked
7563 // plugin forgotten, the next scan that reached it would report them
7564 // again as new, and on a site whose scans are always partial that
7565 // is the same email over and over.
7566 $known = self::is_partial( $results ) ? array_values( array_unique( array_merge( $keys, $told ) ) ) : $keys;
7567
7568 // The record is bounded, and the comparison is not: what does not fit
7569 // in the record is reported again, never left out. The keys come in
7570 // order of importance, so what does not fit is the least of it.
7571 $record = array_slice( $known, 0, 5000 );
7572
7573 if ( empty( array_diff( $keys, $told ) ) ) {
7574 if ( count( $record ) !== count( $told ) ) {
7575 update_option( self::NOTIFIED_OPTION, $record, false );
7576 }
7577 return;
7578 }
7579
7580 if ( $this->send_notification( $results, $notify_level, $closed_plugins ) ) {
7581 update_option( self::NOTIFIED_OPTION, $record, false );
7582 }
7583 }
7584
7585 /**
7586 * One key per finding the scan email would carry at this level
7587 *
7588 * @param array $results Scan results.
7589 * @param array $closed_plugins Closed plugins, keyed by slug.
7590 * @param bool $with_modified Whether files that are only modified travel
7591 * in the email at this level.
7592 * @return array
7593 */
7594 private function notification_keys( $results, $closed_plugins, $with_modified ) {
7595 $keys = array(
7596 'config' => array(),
7597 'closed' => array(),
7598 'suspicious' => array(),
7599 'modified' => array(),
7600 'extra' => array(),
7601 );
7602
7603 foreach ( (array) ( $results['modified'] ?? array() ) as $item ) {
7604 if ( ! is_array( $item ) ) {
7605 continue;
7606 }
7607
7608 $type = (string) ( $item['type'] ?? '' );
7609 $config = 'critical_config' === $type;
7610
7611 // Vigilant's own files have their own alert.
7612 if ( 'vigilante_self' === $type || ( ! $config && ! $with_modified ) ) {
7613 continue;
7614 }
7615
7616 $keys[ $config ? 'config' : 'modified' ][] = md5( 'modified|' . $type . '|' . (string) ( $item['file'] ?? '' ) . '|' . (string) ( $item['actual_hash'] ?? '' ) );
7617 }
7618
7619 foreach ( (array) $closed_plugins as $slug => $info ) {
7620 $keys['closed'][] = md5( 'closed|' . $slug . '|' . ( is_array( $info ) ? (string) ( $info['state'] ?? '' ) : '' ) );
7621 }
7622
7623 foreach ( array( 'suspicious', 'extra' ) as $bucket ) {
7624 foreach ( (array) ( $results[ $bucket ] ?? array() ) as $item ) {
7625 $type = is_array( $item ) ? (string) ( $item['type'] ?? '' ) : '';
7626
7627 if ( 'vigilante_self' === $type ) {
7628 continue;
7629 }
7630
7631 $file = is_array( $item ) ? (string) ( $item['file'] ?? '' ) : (string) $item;
7632 $state = '';
7633 $path = ABSPATH . ltrim( $file, '/' );
7634
7635 // By content, so a file rewritten with the same size and the
7636 // same date is still a different file. The ones too large to
7637 // hash on every scan go by size, date and the time of their
7638 // last change of any kind, which writing to a file moves and
7639 // which cannot be set back the way the date can.
7640 if ( '' !== $file && is_file( $path ) ) {
7641 $state = filesize( $path ) <= MB_IN_BYTES ? (string) md5_file( $path ) : filesize( $path ) . ':' . filemtime( $path ) . ':' . filectime( $path );
7642 }
7643
7644 $keys[ $bucket ][] = md5( $bucket . '|' . $type . '|' . $file . '|' . $state );
7645 }
7646 }
7647
7648 // In order of importance: only the first 5,000 are remembered, and
7649 // what is not remembered is reported again.
7650 return array_values( array_unique( array_merge( $keys['config'], $keys['closed'], $keys['suspicious'], $keys['modified'], $keys['extra'] ) ) );
7651 }
7652
7653 /**
7654 * Collect the closed/removed plugins (excluding ignored slugs) so they can
7655 * be folded into the scan email digest. Returns an array keyed by slug.
7656 *
7657 * @return array
7658 */
7659 private function collect_closed_plugins_for_email() {
7660 if ( empty( $this->options['check_closed_plugins'] ) ) {
7661 return array();
7662 }
7663 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
7664 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
7665 }
7666 $checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
7667 return $checker->get_closed_plugins();
7668 }
7669
7670 /**
7671 * Merge scan results
7672 *
7673 * @param array $results1 First results.
7674 * @param array $results2 Second results.
7675 * @return array Merged results.
7676 */
7677 private function merge_results( $results1, $results2 ) {
7678 return array(
7679 'scanned' => $results1['scanned'] + ( $results2['scanned'] ?? 0 ),
7680 'ok' => $results1['ok'] + ( $results2['ok'] ?? 0 ),
7681 'modified' => array_merge( $results1['modified'], $results2['modified'] ?? array() ),
7682 'missing' => array_merge( $results1['missing'] ?? array(), $results2['missing'] ?? array() ),
7683 'suspicious' => array_merge( $results1['suspicious'] ?? array(), $results2['suspicious'] ?? array() ),
7684 'extra' => array_merge( $results1['extra'] ?? array(), $results2['extra'] ?? array() ),
7685 'new' => $results1['new'] ?? array(),
7686 'errors' => array_merge( $results1['errors'] ?? array(), $results2['errors'] ?? array() ),
7687 // Plugins and themes with no official copy to compare with.
7688 'unverified' => array_merge( $results1['unverified'] ?? array(), $results2['unverified'] ?? array() ),
7689 'scan_time' => $results1['scan_time'] ?? 0,
7690 'incomplete' => $results1['incomplete'] ?? false,
7691 );
7692 }
7693
7694 /**
7695 * Send notification email about scan results
7696 *
7697 * @param array $results Scan results.
7698 * @param string $notify_level Notification level ('all' or 'suspicious_only').
7699 * @param array $closed_plugins Optional map of slug=>state-entry for closed/removed
7700 * plugins to include as a dedicated section.
7701 */
7702 private function send_notification( $results, $notify_level = 'all', $closed_plugins = array() ) {
7703 $to = Vigilante_Email_Template::get_admin_recipients();
7704 $site_name = get_bloginfo( 'name' );
7705
7706 // Split critical_config files from regular modified so they get their own
7707 // prominent section in the email, next to suspicious/extra.
7708 $critical_config = array();
7709 $regular_modified = array();
7710 foreach ( $results['modified'] ?? array() as $item ) {
7711 if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
7712 // Synthesize a reason string with size + line-diff info so get_section_html shows it
7713 $baseline_size = $item['baseline_size'] ?? 0;
7714 $current_size = $item['current_size'] ?? 0;
7715 $added_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['added'] ?? array() ) : 0;
7716 $removed_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['removed'] ?? array() ) : 0;
7717 $diff_unavail = is_array( $item['diff'] ?? null ) && ! empty( $item['diff']['unavailable'] );
7718
7719 $reason = sprintf(
7720 /* translators: 1: baseline size, 2: current size */
7721 __( '%1$s → %2$s bytes', 'vigilante' ),
7722 number_format_i18n( $baseline_size ),
7723 number_format_i18n( $current_size )
7724 );
7725 if ( ! $diff_unavail ) {
7726 $reason .= sprintf( ' (+%d / -%d %s)', $added_count, $removed_count, __( 'lines', 'vigilante' ) );
7727 }
7728
7729 $item['reason'] = $reason;
7730 $critical_config[] = $item;
7731 } else {
7732 $regular_modified[] = $item;
7733 }
7734 }
7735
7736 /*
7737 * Self-protection does not travel in this email any more: it has its own
7738 * alert, which no setting switches off (Vigilante_Self_Integrity::
7739 * maybe_send_self_alert()). Older stored results can still carry its
7740 * rows, so they are dropped here instead of being listed as ordinary
7741 * files.
7742 */
7743 foreach ( array( 'suspicious', 'extra', 'missing' ) as $self_bucket ) {
7744 if ( empty( $results[ $self_bucket ] ) || ! is_array( $results[ $self_bucket ] ) ) {
7745 continue;
7746 }
7747 $results[ $self_bucket ] = array_values(
7748 array_filter(
7749 $results[ $self_bucket ],
7750 function ( $item ) {
7751 return ! ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) );
7752 }
7753 )
7754 );
7755 }
7756 $regular_modified = array_values(
7757 array_filter(
7758 $regular_modified,
7759 function ( $item ) {
7760 return ! ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) );
7761 }
7762 )
7763 );
7764
7765 $suspicious_count = count( $results['suspicious'] ?? array() );
7766 $extra_count = count( $results['extra'] ?? array() );
7767 $critical_config_count = count( $critical_config );
7768 $modified_count = count( $regular_modified );
7769 $closed_count = count( $closed_plugins );
7770
7771 // The subject says what was found. Suspicious files and closed plugins
7772 // keep the alert wording. A change in wp-config.php or .htaccess with
7773 // nothing else next to it names the files instead: most of the time it
7774 // is a plugin rewriting its own block, and "SECURITY ALERT" on every
7775 // one of those taught people to read the subject as "I was hacked".
7776 if ( $suspicious_count > 0 || $closed_count > 0 ) {
7777 $subject = sprintf(
7778 /* translators: %s: Site name */
7779 __( '[%s] SECURITY ALERT: File integrity issues detected', 'vigilante' ),
7780 $site_name
7781 );
7782 } elseif ( $critical_config_count > 0 ) {
7783 $changed = array();
7784 foreach ( $critical_config as $item ) {
7785 $changed[] = (string) ( $item['file'] ?? '' );
7786 }
7787
7788 $subject = sprintf(
7789 /* translators: 1: Site name, 2: The file that changed, or both of them, such as "wp-config.php, .htaccess" */
7790 __( '[%1$s] %2$s changed: review needed', 'vigilante' ),
7791 $site_name,
7792 implode( ', ', array_unique( array_filter( $changed ) ) )
7793 );
7794 } else {
7795 $subject = sprintf(
7796 /* translators: %s: Site name */
7797 __( '[%s] File integrity issues detected', 'vigilante' ),
7798 $site_name
7799 );
7800 }
7801
7802 // Build HTML email using template wrapper
7803 $inner = '';
7804
7805 // Summary counts
7806 $inner .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom:20px;">';
7807 $inner .= '<tr>';
7808 if ( $suspicious_count > 0 ) {
7809 $inner .= $this->get_stat_cell( $suspicious_count, __( 'Suspicious', 'vigilante' ), '#d63638' );
7810 }
7811 if ( $extra_count > 0 ) {
7812 $inner .= $this->get_stat_cell( $extra_count, __( 'Extra', 'vigilante' ), '#b32d2e' );
7813 }
7814 if ( $critical_config_count > 0 ) {
7815 $inner .= $this->get_stat_cell( $critical_config_count, __( 'Critical', 'vigilante' ), '#e36210' );
7816 }
7817 if ( $closed_count > 0 ) {
7818 $inner .= $this->get_stat_cell( $closed_count, __( 'Closed', 'vigilante' ), '#d63638' );
7819 }
7820 if ( 'all' === $notify_level && $modified_count > 0 ) {
7821 $inner .= $this->get_stat_cell( $modified_count, __( 'Modified', 'vigilante' ), '#dba617' );
7822 }
7823 $inner .= $this->get_stat_cell( $results['scanned'] ?? 0, __( 'Scanned', 'vigilante' ), '#50575e' );
7824 $inner .= '</tr></table>';
7825
7826 // Suspicious files section
7827 if ( ! empty( $results['suspicious'] ) ) {
7828 $inner .= $this->get_section_html(
7829 __( 'Suspicious files', 'vigilante' ),
7830 __( 'Files that can run code where none is expected, or that contain code written the way malware hides itself. Some are harmless, such as cache files a plugin generates or a .htaccess that protects a folder: check the path and the reason before acting.', 'vigilante' ),
7831 $results['suspicious'],
7832 '#d63638',
7833 '#fef1f1',
7834 20,
7835 true
7836 );
7837 }
7838
7839 // Extra files section
7840 if ( ! empty( $results['extra'] ) ) {
7841 $inner .= $this->get_section_html(
7842 __( 'Extra files', 'vigilante' ),
7843 __( 'Files that are not part of WordPress, or of the plugin or theme they sit in. Many are legitimate. A copy of wp-config.php or a database dump in the site root should be moved out of it.', 'vigilante' ),
7844 $results['extra'],
7845 '#b32d2e',
7846 '#fdf6f4',
7847 20,
7848 true
7849 );
7850 }
7851
7852 // Critical config files section (wp-config.php, .htaccess modified outside Vigilante)
7853 if ( ! empty( $critical_config ) ) {
7854 $inner .= $this->get_section_html(
7855 __( 'Critical config files modified', 'vigilante' ),
7856 __( 'Changed since you last approved them. Cache, security and redirection plugins rewrite their own blocks in these files, often on every update, and attackers write to them too. Review the changes and approve them if they belong to something you use.', 'vigilante' ),
7857 $critical_config,
7858 '#e36210',
7859 '#fdf2e6',
7860 10,
7861 true
7862 );
7863 }
7864
7865 // Modified files section (only if notify_level is 'all')
7866 if ( 'all' === $notify_level && ! empty( $regular_modified ) ) {
7867 $inner .= $this->get_section_html(
7868 __( 'Modified files', 'vigilante' ),
7869 __( 'These files do not match the copy WordPress.org publishes for the installed version. Reinstalling the plugin, the theme or WordPress brings the original back.', 'vigilante' ),
7870 $regular_modified,
7871 '#dba617',
7872 '#fdf8e8',
7873 15,
7874 false
7875 );
7876 }
7877
7878 // Closed + Removed plugins section.
7879 // Same tier as suspicious files: WordPress.org has flagged the plugin as
7880 // closed or removed, the site keeps running its code, and ignoring the
7881 // finding is an explicit per-slug action by the admin.
7882 if ( $closed_count > 0 ) {
7883 $inner .= $this->build_closed_plugins_email_section( $closed_plugins );
7884 }
7885
7886 // A partial scan says so and names what it did not get to. With no
7887 // finding in a plugin that was never looked at, silence here read as
7888 // "that plugin is fine".
7889 if ( self::is_partial( $results ) ) {
7890 $partial_lines = array_merge(
7891 array_slice( array_map( 'strval', (array) ( $results['errors'] ?? array() ) ), 0, 10 ),
7892 self::not_checked_lines( $results )
7893 );
7894
7895 $inner .= '<p style="margin:0 0 8px;font-size:13px;color:#1d2327;"><strong>' . esc_html__( 'The last scan could not check everything, so its result is partial.', 'vigilante' ) . '</strong></p>';
7896 $inner .= '<ul style="margin:0 0 16px 18px;padding:0;font-size:13px;color:#50575e;">';
7897
7898 foreach ( $partial_lines as $partial_line ) {
7899 $inner .= '<li>' . esc_html( $partial_line ) . '</li>';
7900 }
7901
7902 $inner .= '</ul>';
7903 }
7904
7905 // Where the explanation is. The email stays a list; what each kind of
7906 // finding usually is and what to do about it lives next to the results.
7907 $inner .= '<p style="margin:0 0 16px;font-size:12px;color:#50575e;">' . esc_html__( 'File Integrity explains what each kind of finding usually is and what to do about it. A finding stays listed until you approve it, ignore it or fix it.', 'vigilante' ) . '</p>';
7908
7909 // CTA button
7910 $inner .= Vigilante_Email_Template::button(
7911 admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-last-scan' ),
7912 __( 'Review in Vigilant', 'vigilante' )
7913 );
7914
7915 $is_alert = ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 );
7916
7917 // Same three cases as the subject.
7918 if ( $suspicious_count > 0 || $closed_count > 0 ) {
7919 $title = __( 'Security alert', 'vigilante' );
7920 } elseif ( $critical_config_count > 0 ) {
7921 $title = __( 'Configuration file changed', 'vigilante' );
7922 } else {
7923 $title = __( 'File integrity report', 'vigilante' );
7924 }
7925
7926 // The caller records what was reported only when the email went out.
7927 return (bool) Vigilante_Email_Template::send( $to, $subject, $title, $inner, $is_alert );
7928 }
7929
7930 /**
7931 * Build the closed + removed plugins block for the scan email.
7932 *
7933 * Reuses the same visual treatment as the suspicious files section
7934 * (red accent, danger description) because the security tier is the
7935 * same: WordPress.org has marked the plugin as compromised or removed.
7936 *
7937 * @param array $closed_plugins Map of slug=>state entry.
7938 * @return string HTML block.
7939 */
7940 private function build_closed_plugins_email_section( $closed_plugins ) {
7941 $color = '#d63638';
7942 $bg_color = '#fef1f1';
7943 $title = __( 'Closed + Removed plugins', 'vigilante' );
7944 $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' );
7945
7946 $html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">';
7947 $html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>';
7948 $html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $desc ) . '</p>';
7949
7950 $html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">';
7951 foreach ( $closed_plugins as $slug => $entry ) {
7952 $name = isset( $entry['name'] ) ? $entry['name'] : $slug;
7953 $version = isset( $entry['version'] ) ? $entry['version'] : '';
7954 $state = isset( $entry['state'] ) ? $entry['state'] : '';
7955 $state_label = 'closed' === $state ? __( 'Closed', 'vigilante' ) : __( 'Removed', 'vigilante' );
7956 $closed_date = isset( $entry['closed_date'] ) ? $entry['closed_date'] : '';
7957 $reason = isset( $entry['closed_reason_text'] ) && '' !== $entry['closed_reason_text']
7958 ? $entry['closed_reason_text']
7959 : '';
7960
7961 $detail_bits = array();
7962 $detail_bits[] = $state_label;
7963 if ( '' !== $closed_date ) {
7964 $detail_bits[] = esc_html( $closed_date );
7965 }
7966 if ( '' !== $version ) {
7967 $detail_bits[] = 'v' . esc_html( $version );
7968 }
7969
7970 $html .= '<tr>';
7971 $html .= '<td style="padding:4px 0;color:#1d2327;font-family:Consolas,Monaco,monospace;font-size:11px;word-break:break-all;">';
7972 $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>';
7973 $html .= '</td></tr>';
7974 $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>';
7975 if ( '' !== $reason ) {
7976 $html .= '<tr><td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td></tr>';
7977 }
7978 }
7979 $html .= '</table>';
7980 $html .= '</div>';
7981
7982 return $html;
7983 }
7984
7985 /**
7986 * Get a summary stat cell for email
7987 *
7988 * @param int $count Stat count.
7989 * @param string $label Stat label.
7990 * @param string $color Color hex.
7991 * @return string HTML table cell.
7992 */
7993 private function get_stat_cell( $count, $label, $color ) {
7994 $html = '<td style="text-align:center;padding:12px 8px;">';
7995 $html .= '<div style="font-size:24px;font-weight:700;color:' . $color . ';line-height:1.2;">' . (int) $count . '</div>';
7996 $html .= '<div style="font-size:11px;color:#50575e;text-transform:uppercase;letter-spacing:0.5px;">' . esc_html( $label ) . '</div>';
7997 $html .= '</td>';
7998
7999 return $html;
8000 }
8001
8002 /**
8003 * Get an HTML section for file list in email
8004 *
8005 * @param string $title Section title.
8006 * @param string $description Section description.
8007 * @param array $files Array of file items.
8008 * @param string $color Accent color.
8009 * @param string $bg_color Background color.
8010 * @param int $max Max files to show.
8011 * @param bool $show_reason Whether to show reason column.
8012 * @return string HTML.
8013 */
8014 private function get_section_html( $title, $description, $files, $color, $bg_color, $max, $show_reason ) {
8015 $total = count( $files );
8016 $shown = array_slice( $files, 0, $max );
8017
8018 $html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">';
8019 $html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>';
8020 $html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $description ) . '</p>';
8021
8022 $html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">';
8023 foreach ( $shown as $file ) {
8024 $file_path = is_array( $file ) ? ( $file['file'] ?? '' ) : (string) $file;
8025 $reason = is_array( $file ) ? ( $file['reason'] ?? '' ) : '';
8026
8027 $html .= '<tr>';
8028 $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>';
8029 $html .= '</tr>';
8030
8031 if ( $show_reason && ! empty( $reason ) ) {
8032 $html .= '<tr>';
8033 $html .= '<td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td>';
8034 $html .= '</tr>';
8035 }
8036 }
8037 $html .= '</table>';
8038
8039 if ( $total > $max ) {
8040 $html .= '<p style="margin:8px 0 0;font-size:12px;color:#787c82;">';
8041 /* translators: %d: Number of additional files */
8042 $html .= sprintf( esc_html__( '... and %d more', 'vigilante' ), $total - $max );
8043 $html .= '</p>';
8044 }
8045
8046 $html .= '</div>';
8047
8048 return $html;
8049 }
8050
8051 /**
8052 * Add a file to the ignored list
8053 *
8054 * The path is stored exactly as it comes: the callers are the AJAX
8055 * handlers, which have already read it and checked it against the shared
8056 * files and Vigilant's own, and the scan compares the stored strings with
8057 * the paths it finds one by one (in_array(), strict). This used to run
8058 * sanitize_text_field() over it, which deletes every %XX octet and collapses
8059 * repeated spaces, so the entry never matched the file it was made for
8060 * (measured on 1 oct 2026 against 3.0.1). Anything that is not a plain
8061 * string, is empty or carries tags is not a path and is refused. That guards
8062 * a direct caller: the AJAX handlers read the path with wp_strip_all_tags()
8063 * first, which shortens a name with a stray < instead of handing it over
8064 * intact (see ajax_ignore_file()).
8065 *
8066 * @param string $file_path Relative file path to ignore.
8067 * @return bool
8068 */
8069 public function ignore_file( $file_path ) {
8070 if ( ! is_string( $file_path ) || '' === $file_path || wp_strip_all_tags( $file_path ) !== $file_path ) {
8071 return false;
8072 }
8073
8074 $ignored = get_option( 'vigilante_ignored_files', array() );
8075
8076 if ( ! in_array( $file_path, $ignored, true ) ) {
8077 $ignored[] = $file_path;
8078 return update_option( 'vigilante_ignored_files', $ignored );
8079 }
8080
8081 return true;
8082 }
8083
8084 /**
8085 * Remove a file from the ignored list
8086 *
8087 * @param string $file_path Relative file path to stop ignoring.
8088 * @return bool
8089 */
8090 public function unignore_file( $file_path ) {
8091 $ignored = get_option( 'vigilante_ignored_files', array() );
8092 $ignored = array_values( array_diff( $ignored, array( $file_path ) ) );
8093
8094 return update_option( 'vigilante_ignored_files', $ignored );
8095 }
8096
8097 /**
8098 * Get the list of ignored files
8099 *
8100 * @return array
8101 */
8102 public function get_ignored_files() {
8103 return get_option( 'vigilante_ignored_files', array() );
8104 }
8105
8106 /**
8107 * Clear all ignored files
8108 *
8109 * @return bool
8110 */
8111 public function clear_ignored_files() {
8112 return delete_option( 'vigilante_ignored_files' );
8113 }
8114
8115 /**
8116 * Get last scan results
8117 *
8118 * @return array|false
8119 */
8120 public function get_last_scan_results() {
8121 return get_option( 'vigilante_last_integrity_results', false );
8122 }
8123
8124 /**
8125 * Get last scan time
8126 *
8127 * @return int|false
8128 */
8129 public function get_last_scan_time() {
8130 return get_option( 'vigilante_last_integrity_scan', false );
8131 }
8132
8133 /**
8134 * Clear stored hashes
8135 *
8136 * @return bool
8137 */
8138 public function clear_hashes() {
8139 return $this->database->clear_file_hashes();
8140 }
8141 }