PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
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
← All changes | includes/class-file-integrity.php +5185 -413 2.11.3 → 3.0.5 View file →
@@ -68,15 +68,50 @@
68 68 */
69 69 private $scan_start_time = 0;
70 70
71 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 + /**
72 81 * Maximum scan time in seconds (default 60s for thorough scanning)
73 82 *
74 83 * @var int
75 84 */
76 - private $max_scan_time = 60;
85 + private $max_scan_time = self::MAX_SCAN_SECONDS;
77 86
78 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 + /**
79 114 * Option name for critical files baseline hashes.
80 115 *
81 116 * @var string
82 117 */
@@ -96,8 +131,66 @@
96 131 */
97 132 const BASELINE_SWEEP_OPTION = 'vigilante_baseline_sweep';
98 133
99 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 + /**
100 193 * What replaces a secret value kept in the baseline.
101 194 *
102 195 * Fixed forever: if this string ever changes, every stored baseline
103 196 * suddenly differs from the freshly redacted file and every site reports a
@@ -107,13 +200,16 @@
107 200 */
108 201 const REDACTED_MARKER = '[redacted by Vigilant]';
109 202
110 203 /**
111 - * Constant names whose value never reaches the database.
204 + * Constant names whose value is checked even where the file does not name them
112 205 *
113 - * The eight WordPress keys and salts and the database credentials, plus
114 - * anything whose name reads like a credential, since a real wp-config.php
115 - * collects SMTP passwords, S3 keys and API tokens over the years.
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().
116 212 *
117 213 * @since 2.11.2
118 214 *
119 215 * @var string[]
@@ -124,8 +220,36 @@
124 220 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
125 221 );
126 222
127 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 + /**
128 252 * Read the critical files baseline, from where it belongs
129 253 *
130 254 * Both watched files, wp-config.php and the root .htaccess, belong to the
131 255 * whole network: there is one of each per installation, not one per site.
@@ -131,9 +255,9 @@
131 255 * whole network: there is one of each per installation, not one per site.
132 256 * Keeping the baseline in a per-site option meant every site of a network
133 257 * stored its own copy of the same wp-config.php, so a network of fifty
134 258 * sites held fifty copies of the same credentials, and a cleanup that ran
135 - * on one site left the other forty nine untouched. Reported by Albert on
259 + * on one site left the other forty nine untouched. Reported by @calzbert on
136 260 * 10 sep 2026 and reproduced on the Multisite install. Since 2.11.3 there
137 261 * is one baseline per network.
138 262 *
139 263 * No is_multisite() branch on purpose, and this is worth reading before
@@ -167,8 +291,112 @@
167 291 return update_site_option( self::BASELINE_OPTION, $baseline );
168 292 }
169 293
170 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 + /**
171 399 * The baseline copy of a critical file, with no secret in it
172 400 *
173 401 * The integrity scan keeps a copy of wp-config.php so it can show which
174 402 * lines changed. Until 2.11.1 that copy was the file itself minus the
@@ -188,35 +416,61 @@
188 416 * @param string $normalized Normalized content.
189 417 * @return string Content safe to store, or '' when it cannot be made safe.
190 418 */
191 419 private function baseline_content( $filename, $normalized ) {
420 + if ( '.htaccess' === $filename ) {
421 + return $this->redact_server_secrets( $normalized );
422 + }
423 +
192 424 if ( 'wp-config.php' !== $filename ) {
193 425 return $normalized;
194 426 }
195 427
196 - $redacted = $this->redact_secrets( $normalized );
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 );
197 432
198 433 /*
199 - * Belt and braces, and this is the part that matters: the regular
200 - * expression above is the thing most likely to miss a shape nobody
201 - * thought of, and the cost of missing one is a secret in the database.
202 - * So the result is checked against the values actually in force, and
203 - * if any of them survived, nothing is stored at all. The scan then
204 - * reports the change without a line diff, which the interface already
205 - * handles, instead of leaking.
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.
206 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 + *
207 455 * Only values of eight characters or more are checked: DB_NAME is
208 456 * often something like "local" or "wp", and looking for that inside a
209 457 * PHP file matches by accident every time.
210 458 */
211 - foreach ( self::$secret_constants as $name ) {
212 - if ( ! defined( $name ) ) {
213 - continue;
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 + }
214 470 }
215 471
216 - $value = (string) constant( $name );
217 -
218 - if ( strlen( $value ) >= 8 && false !== strpos( $redacted, $value ) ) {
472 + if ( false !== strpos( $redacted, $value ) ) {
219 473 return '';
220 474 }
221 475 }
222 476
@@ -223,44 +477,865 @@
223 477 return $redacted;
224 478 }
225 479
226 480 /**
227 - * Replace the value of every credential-looking define with a marker
481 + * Replace every value in wp-config.php with a marker
228 482 *
229 - * Keeps the line and the constant name, so the diff still shows that a
230 - * credential line was touched, and drops only the value.
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.
231 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 + *
232 504 * @since 2.11.2
505 + * @since 2.11.8 Reads tokens and redacts every value.
233 506 *
234 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.
235 803 * @return string
236 804 */
237 - private function redact_secrets( $content ) {
238 - $names = implode( '|', array_map( 'preg_quote', self::$secret_constants ) );
805 + private function redact_comment( $comment ) {
806 + $marker = self::REDACTED_MARKER;
807 + $readable = self::$readable_constants;
239 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 +
240 936 /*
241 - * define( 'NAME', 'value' ), matching the quoted literal itself rather
242 - * than reading up to the closing parenthesis.
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.
243 1053 *
244 - * The first version of this stopped at the first ';', which looked
245 - * reasonable and failed on the very first real file it saw: the salts
246 - * WordPress generates are full of punctuation and a ';' inside one cut
247 - * the match short, so nothing was redacted. Backreference 3 is the
248 - * opening quote and the value runs to its unescaped twin. No /s
249 - * modifier on purpose, so a file with an unterminated string breaks
250 - * one line instead of swallowing the rest of the file.
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.
251 1070 */
252 - $pattern = '/(define\s*\(\s*([\'"])(?:' . $names . '|[A-Z0-9_]*(?:KEY|SALT|SECRET|PASSWORD|PASSWD|TOKEN|API)[A-Z0-9_]*)\2\s*,\s*)([\'"])(?:\\\\.|(?!\3).)*\3/i';
1071 + $sources = array( $network );
253 1072
254 - $redacted = preg_replace( $pattern, '${1}\'' . self::REDACTED_MARKER . '\'', $content );
1073 + if ( ! is_main_site() ) {
1074 + $from_main = get_blog_option( get_main_site_id(), self::BASELINE_OPTION, null );
255 1075
256 - // A failed preg_replace returns null, and storing null would wipe the
257 - // baseline copy silently. Falling back to the original is not an
258 - // option either, so the caller's guard turns this into "no content".
259 - return ( null === $redacted ) ? '' : $redacted;
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;
260 1159 }
261 1160
262 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 + /**
263 1338 * Clean up what earlier versions stored, wherever they stored it
264 1339 *
265 1340 * Two jobs, and the second one only exists on a network.
266 1341 *
@@ -287,49 +1362,32 @@
287 1362 }
288 1363
289 1364 /*
290 1365 * The per-site copy left behind by 2.11.2 and earlier. On a network it
291 - * holds the database password and the eight keys and salts, so it goes.
292 - *
293 - * But it does not just go: what it records is which version of the file
294 - * the owner approved, and that has to survive. Dropping it and letting
295 - * the scan build a fresh baseline from the file would take whatever is
296 - * on disk right now as approved, so a wp-config.php modified and still
297 - * awaiting review would be silently blessed and the warning would
298 - * disappear on update. Measured on the Multisite install while writing
299 - * this: the first scan after the migration reported zero modified files
300 - * where it had to report one.
301 - *
302 - * So the hash and the size are promoted to the network baseline, which
303 - * is what says "this is the version that was approved", and only the
304 - * content is redacted on the way, which is the part that carries the
305 - * secrets.
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.
306 1373 */
1374 + $pending = false;
1375 +
307 1376 if ( is_multisite() ) {
308 1377 $per_site = get_option( self::BASELINE_OPTION, null );
309 1378
310 1379 if ( null !== $per_site ) {
311 - if ( is_array( $per_site ) && ! get_site_option( self::BASELINE_OPTION, false ) ) {
312 - $promoted = array();
313 -
314 - foreach ( $per_site as $filename => $data ) {
315 - if ( ! is_array( $data ) || ! isset( $data['hash'] ) ) {
316 - continue;
317 - }
318 -
319 - if ( isset( $data['content'] ) && is_string( $data['content'] ) ) {
320 - $data['content'] = $this->baseline_content( $filename, $data['content'] );
321 - }
322 -
323 - $promoted[ $filename ] = $data;
324 - }
325 -
326 - if ( $promoted ) {
327 - $this->write_baseline( $promoted );
328 - }
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;
329 1389 }
330 -
331 - delete_option( self::BASELINE_OPTION );
332 1390 }
333 1391 }
334 1392
335 1393 $baseline = $this->read_baseline();
@@ -354,9 +1412,26 @@
354 1412 $this->write_baseline( $baseline );
355 1413 }
356 1414 }
357 1415
358 - update_option( self::BASELINE_REDACTION_OPTION, VIGILANTE_VERSION, false );
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 + }
359 1434 }
360 1435
361 1436 /**
362 1437 * Sweep the whole network once, so it does not wait for each site's cron
@@ -394,26 +1469,58 @@
394 1469 if ( ! current_user_can( 'manage_network_options' ) ) {
395 1470 return;
396 1471 }
397 1472
398 - if ( VIGILANTE_VERSION === get_site_option( self::BASELINE_SWEEP_OPTION ) ) {
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 ) {
399 1498 return;
400 1499 }
401 1500
402 - // Marked before the walk, not after: on a very large network the walk
403 - // may not finish inside one request, and repeating it on every single
404 - // admin request would be worse than leaving the rest to each site own
405 - // scan, which cleans them anyway.
1501 + if ( VIGILANTE_VERSION === $marker ) {
1502 + return;
1503 + }
1504 +
406 1505 update_site_option( self::BASELINE_SWEEP_OPTION, VIGILANTE_VERSION );
407 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()).
408 1512 $site_ids = get_sites(
409 1513 array(
410 1514 'fields' => 'ids',
411 1515 'number' => 0,
1516 + 'network_id' => get_current_network_id(),
412 1517 'update_site_meta_cache' => false,
413 1518 )
414 1519 );
415 1520
1521 + $pending = 0;
1522 +
416 1523 foreach ( $site_ids as $site_id ) {
417 1524 switch_to_blog( $site_id );
418 1525
419 1526 $per_site = get_option( self::BASELINE_OPTION, null );
@@ -418,41 +1525,28 @@
418 1525
419 1526 $per_site = get_option( self::BASELINE_OPTION, null );
420 1527
421 1528 if ( null !== $per_site ) {
422 - /*
423 - * Same care as the per-site cleanup: what a copy records is
424 - * which version was approved, so the last one standing is
425 - * promoted before it goes. Without this, a network whose main
426 - * site had never run a scan would lose the only record it had,
427 - * and a wp-config.php modified and awaiting review would be
428 - * approved in silence.
429 - */
430 - if ( is_array( $per_site ) && ! get_site_option( self::BASELINE_OPTION, false ) ) {
431 - $promoted = array();
432 -
433 - foreach ( $per_site as $filename => $data ) {
434 - if ( ! is_array( $data ) || ! isset( $data['hash'] ) ) {
435 - continue;
436 - }
437 -
438 - if ( isset( $data['content'] ) && is_string( $data['content'] ) ) {
439 - $data['content'] = $this->baseline_content( $filename, $data['content'] );
440 - }
441 -
442 - $promoted[ $filename ] = $data;
443 - }
444 -
445 - if ( $promoted ) {
446 - $this->write_baseline( $promoted );
447 - }
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++;
448 1536 }
449 -
450 - delete_option( self::BASELINE_OPTION );
451 1537 }
452 1538
453 1539 restore_current_blog();
454 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 + }
455 1549 }
456 1550
457 1551 /**
458 1552 * Critical root files to monitor against a stored baseline.
@@ -493,16 +1587,25 @@
493 1587 array( '# BEGIN Vigilante Security Headers', '# END Vigilante Security Headers' ),
494 1588 );
495 1589
496 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 + /**
497 1600 * Core files known to produce false positives in checksum comparison.
498 - * These are skipped during core scanning (e.g. version.php is rewritten
499 - * during auto-updates and localized installs, readme files vary by locale).
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()).
500 1604 *
501 1605 * @var array
502 1606 */
503 1607 private $core_known_false_positives = array(
504 - 'wp-includes/version.php',
505 1608 'readme.html',
506 1609 'license.txt',
507 1610 'licencia.txt',
508 1611 );
@@ -584,9 +1687,24 @@
584 1687 $this->activity_log = $activity_log;
585 1688 $this->options = $settings ? $settings->get_section( 'file_integrity' ) : array();
586 1689 $this->wp_version = get_bloginfo( 'version' );
587 1690 $this->ignored_files = get_option( 'vigilante_ignored_files', array() );
1691 + }
588 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() {
589 1707 // Schedule automated scans only if options available
590 1708 if ( ! empty( $this->options['auto_scan'] ) ) {
591 1709 add_action( 'vigilante_file_integrity_scan', array( $this, 'run_scheduled_scan' ) );
592 1710 $this->schedule_scan();
@@ -596,16 +1714,37 @@
596 1714 // WordPress.org immediately, and open a short grace window so the
597 1715 // scheduled scan does not raise false positives while wp.org is still
598 1716 // publishing the new version's checksums. Registered regardless of
599 1717 // auto_scan because it reacts to update events, not to the schedule.
600 - // Clean the secrets an earlier version stored in the baseline. On
601 - // admin_init because that is where the baseline is looked at, and it
602 - // does one option read per admin request until it has run once.
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() {
603 1744 add_action( 'admin_init', array( $this, 'maybe_redact_stored_baseline' ) );
604 1745 add_action( 'admin_init', array( $this, 'maybe_sweep_network_baselines' ) );
605 -
606 - add_action( 'upgrader_process_complete', array( $this, 'on_upgrade_complete' ), 20, 2 );
607 - add_action( 'vigilante_fi_postupdate_verify', array( $this, 'run_postupdate_verify' ) );
1746 + add_action( 'admin_init', array( $this, 'maybe_claim_owned_blocks_on_admin' ) );
608 1747 }
609 1748
610 1749 /**
611 1750 * Check if scan time limit has been exceeded
@@ -676,8 +1815,16 @@
676 1815 $files = array( $hook_extra['plugin'] );
677 1816 }
678 1817 foreach ( $files as $file ) {
679 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 + }
680 1827 if ( '.' !== $slug && '' !== $slug ) {
681 1828 $targets['plugin'][] = $slug;
682 1829 }
683 1830 }
@@ -746,8 +1893,17 @@
746 1893 */
747 1894 private function verify_updated_slug( $type, $slug ) {
748 1895 $grace_key = 'vigilante_fi_grace_' . $type . '_' . md5( $slug );
749 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 +
750 1906 if ( 'plugin' === $type ) {
751 1907 if ( ! function_exists( 'get_plugins' ) ) {
752 1908 require_once ABSPATH . 'wp-admin/includes/plugin.php';
753 1909 }
@@ -898,8 +2054,56 @@
898 2054
899 2055 // Use settings from options page
900 2056 $options = is_array( $this->options ) ? $this->options : array();
901 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 +
902 2106 // Scan uploads for suspicious files FIRST (highest security priority)
903 2107 // PHP files in uploads are almost always malware
904 2108 if ( ! empty( $options['scan_uploads'] ) && ! $this->is_time_exceeded() ) {
905 2109 $upload_results = $this->scan_uploads();
@@ -904,8 +2108,11 @@
904 2108 if ( ! empty( $options['scan_uploads'] ) && ! $this->is_time_exceeded() ) {
905 2109 $upload_results = $this->scan_uploads();
906 2110 $results['suspicious'] = array_merge( $results['suspicious'], $upload_results['suspicious'] );
907 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';
908 2115 }
909 2116
910 2117 // Scan core files
911 2118 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
@@ -910,8 +2117,10 @@
910 2117 // Scan core files
911 2118 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
912 2119 $core_results = $this->scan_core_files();
913 2120 $results = $this->merge_results( $results, $core_results );
2121 + } elseif ( ! empty( $options['scan_core'] ) ) {
2122 + $not_checked['areas'][] = 'core';
914 2123 }
915 2124
916 2125 // Scan root directory for non-core files (PHP = suspicious, others = additional)
917 2126 // Runs after core scan so checksums are already cached
@@ -918,20 +2127,61 @@
918 2127 if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) {
919 2128 $root_results = $this->scan_root_files();
920 2129 $results['suspicious'] = array_merge( $results['suspicious'], $root_results['suspicious'] );
921 2130 $results['extra'] = array_merge( $results['extra'], $root_results['extra'] );
2131 + } elseif ( ! empty( $options['scan_core'] ) ) {
2132 + $not_checked['areas'][] = 'core';
922 2133 }
923 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 +
924 2146 // Scan critical config files (wp-config.php, .htaccess) against stored baseline
925 2147 if ( ! empty( $options['scan_critical_config'] ) && ! $this->is_time_exceeded() ) {
926 2148 $critical_results = $this->scan_critical_root_files();
927 2149 $results['modified'] = array_merge( $results['modified'], $critical_results );
2150 + } elseif ( ! empty( $options['scan_critical_config'] ) ) {
2151 + $not_checked['areas'][] = 'config';
928 2152 }
929 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 +
930 2170 // Scan plugins
931 2171 if ( ! empty( $options['scan_plugins'] ) && ! $this->is_time_exceeded() ) {
932 2172 $plugin_results = $this->scan_plugins();
933 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';
934 2184 }
935 2185
936 2186 // Scan themes
937 2187 if ( ! empty( $options['scan_themes'] ) && ! $this->is_time_exceeded() ) {
@@ -936,10 +2186,21 @@
936 2186 // Scan themes
937 2187 if ( ! empty( $options['scan_themes'] ) && ! $this->is_time_exceeded() ) {
938 2188 $theme_results = $this->scan_themes();
939 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';
940 2195 }
941 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 +
942 2203 // Mark as incomplete if time was exceeded
943 2204 if ( $this->is_time_exceeded() ) {
944 2205 $results['incomplete'] = true;
945 2206 $results['errors'][] = __( 'Scan was incomplete due to time limit. Results may be partial.', 'vigilante' );
@@ -944,8 +2205,19 @@
944 2205 $results['incomplete'] = true;
945 2206 $results['errors'][] = __( 'Scan was incomplete due to time limit. Results may be partial.', 'vigilante' );
946 2207 }
947 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 +
948 2220 // Filter out ignored files from all result categories
949 2221 $results['modified'] = $this->filter_ignored( $results['modified'] );
950 2222 $results['suspicious'] = $this->filter_ignored( $results['suspicious'] );
951 2223 $results['extra'] = $this->filter_ignored( $results['extra'] );
@@ -956,14 +2228,21 @@
956 2228 if ( $this->activity_log ) {
957 2229 $has_issues = ! empty( $results['modified'] ) || ! empty( $results['suspicious'] ) || ! empty( $results['extra'] );
958 2230 $severity = $has_issues ? 'warning' : 'info';
959 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 +
960 2240 $this->activity_log->log(
961 2241 'file',
962 2242 'integrity_scan',
963 2243 sprintf(
964 - /* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */
965 - __( 'File integrity scan completed: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' ),
2244 + $log_text,
966 2245 $results['scanned'],
967 2246 count( $results['modified'] ),
968 2247 count( $results['suspicious'] ),
969 2248 count( $results['extra'] )
@@ -1018,9 +2297,10 @@
1018 2297 'errors' => array(),
1019 2298 );
1020 2299
1021 2300 // Get official checksums from WordPress.org
1022 - $checksums = $this->get_core_checksums();
2301 + $this->core_checksums_are_english = false;
2302 + $checksums = $this->get_core_checksums();
1023 2303
1024 2304 if ( is_wp_error( $checksums ) ) {
1025 2305 $results['errors'][] = $checksums->get_error_message();
1026 2306 return $results;
@@ -1025,8 +2305,37 @@
1025 2305 $results['errors'][] = $checksums->get_error_message();
1026 2306 return $results;
1027 2307 }
1028 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 +
1029 2338 foreach ( $checksums as $file => $expected_hash ) {
1030 2339 // Check time limit
1031 2340 if ( $this->is_time_exceeded() ) {
1032 2341 break;
@@ -1043,9 +2352,9 @@
1043 2352 if ( $this->is_extension_excluded( $file_path ) ) {
1044 2353 continue;
1045 2354 }
1046 2355
1047 - // Skip known false positives (e.g. version.php, readme.html)
2356 + // Skip known false positives (e.g. readme.html, license.txt)
1048 2357 if ( in_array( $file, $this->core_known_false_positives, true ) ) {
1049 2358 continue;
1050 2359 }
1051 2360
@@ -1060,8 +2369,30 @@
1060 2369 || 0 === strpos( $file, 'wp-content/languages/themes/' ) ) {
1061 2370 continue;
1062 2371 }
1063 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 +
1064 2395 $results['scanned']++;
1065 2396
1066 2397 if ( ! file_exists( $file_path ) ) {
1067 2398 $results['missing'][] = array(
@@ -1072,9 +2403,22 @@
1072 2403 }
1073 2404
1074 2405 $actual_hash = md5_file( $file_path );
1075 2406
1076 - if ( $actual_hash !== $expected_hash ) {
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 +
1077 2421 $results['modified'][] = array(
1078 2422 'file' => $file,
1079 2423 'type' => 'core',
1080 2424 'expected_hash' => $expected_hash,
@@ -1188,9 +2532,9 @@
1188 2532
1189 2533 // Also add wp-config.php which is not in checksums but is core
1190 2534 $core_root_files[] = 'wp-config.php';
1191 2535
1192 - $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
2536 + $php_extensions = self::php_extensions();
1193 2537
1194 2538 // Scan only direct children of ABSPATH (not recursive)
1195 2539 $root_path = untrailingslashit( ABSPATH );
1196 2540 $handle = opendir( $root_path );
@@ -1208,9 +2552,22 @@
1208 2552 if ( '.' === $entry || '..' === $entry ) {
1209 2553 continue;
1210 2554 }
1211 2555
1212 - // Skip dotfiles (.htaccess, .user.ini, .env, etc.) — handled by firewall protection
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
1213 2570 if ( 0 === strpos( $entry, '.' ) ) {
1214 2571 continue;
1215 2572 }
1216 2573
@@ -1235,8 +2592,13 @@
1235 2592 if ( in_array( strtolower( $entry ), $this->known_safe_root_files, true ) ) {
1236 2593 continue;
1237 2594 }
1238 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 +
1239 2601 $extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) );
1240 2602
1241 2603 if ( in_array( $extension, $php_extensions, true ) ) {
1242 2604 // Check if this is a legacy WordPress core file (removed from newer versions)
@@ -1258,15 +2620,12 @@
1258 2620 // PHP file not in core = suspicious
1259 2621 $reason = __( 'Non-core PHP file in WordPress root directory', 'vigilante' );
1260 2622
1261 2623 // Scan content for specific patterns
1262 - if ( filesize( $full_path ) < 512000 ) {
1263 - $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1264 - $pattern = $this->detect_suspicious_pattern( $content );
1265 - if ( $pattern ) {
1266 - /* translators: %s: Suspicious pattern found */
1267 - $reason = sprintf( __( 'Non-core PHP in root with suspicious code: %s', 'vigilante' ), $pattern );
1268 - }
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 );
1269 2628 }
1270 2629
1271 2630 $found['suspicious'][] = array(
1272 2631 'file' => $entry,
@@ -1273,8 +2632,20 @@
1273 2632 'type' => 'php_in_root',
1274 2633 'reason' => $reason,
1275 2634 );
1276 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 +
1277 2648 // Non-PHP, non-known-safe file = additional (informational)
1278 2649 $found['extra'][] = array(
1279 2650 'file' => $entry,
1280 2651 'type' => 'extra_root',
@@ -1287,8 +2658,864 @@
1287 2658
1288 2659 return $found;
1289 2660 }
1290 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 +
1291 3518 // =========================================================================
1292 3519 // Critical config file baseline monitoring (wp-config.php, .htaccess)
1293 3520 // =========================================================================
1294 3521
@@ -1304,8 +3531,51 @@
1304 3531 * silently — there is nothing to compare against.
1305 3532 *
1306 3533 * @return array Array of modified file entries (same format as core modified).
1307 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 +
1308 3578 private function scan_critical_root_files() {
1309 3579 // Before reading anything: the scan is the only thing that reaches
1310 3580 // every site of a network on its own, through wp-cron and front-end
1311 3581 // traffic. Hooking the cleanup to admin_init alone left every subsite
@@ -1312,17 +3582,20 @@
1312 3582 // whose dashboard nobody opens with its old copy of wp-config.php,
1313 3583 // credentials included, for as long as nobody visited it.
1314 3584 $this->maybe_redact_stored_baseline();
1315 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 +
1316 3590 $modified = array();
1317 3591 $baseline = $this->get_critical_files_baseline();
1318 3592 $baseline_changed = false;
1319 - $root_path = untrailingslashit( ABSPATH );
1320 3593
1321 3594 foreach ( $this->critical_root_files as $filename ) {
1322 - $full_path = $root_path . '/' . $filename;
3595 + $full_path = $this->critical_file_path( $filename );
1323 3596
1324 - if ( ! file_exists( $full_path ) ) {
3597 + if ( false === $full_path ) {
1325 3598 continue;
1326 3599 }
1327 3600
1328 3601 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
@@ -1373,12 +3646,31 @@
1373 3646
1374 3647 continue;
1375 3648 }
1376 3649
1377 - // Compare against stored baseline
1378 - if ( $baseline[ $filename ]['hash'] !== $current_hash ) {
1379 - // Both sides go through the same redaction, or every
1380 - // credential line would read as a change nobody made.
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 {
1381 3673 $baseline_content = $baseline[ $filename ]['content'] ?? '';
1382 3674 $current_content = $this->baseline_content( $filename, $normalized );
1383 3675 $diff = ( '' !== $baseline_content && '' !== $current_content )
1384 3676 ? $this->compute_simple_diff( $baseline_content, $current_content )
@@ -1383,18 +3675,25 @@
1383 3675 $diff = ( '' !== $baseline_content && '' !== $current_content )
1384 3676 ? $this->compute_simple_diff( $baseline_content, $current_content )
1385 3677 : array( 'added' => array(), 'removed' => array(), 'unavailable' => true );
1386 3678
1387 - $modified[] = array(
1388 - 'file' => $filename,
1389 - 'type' => 'critical_config',
1390 - 'expected_hash' => $baseline[ $filename ]['hash'],
1391 - 'actual_hash' => $current_hash,
1392 - 'baseline_size' => $baseline[ $filename ]['size'],
1393 - 'current_size' => strlen( $content ),
1394 - 'diff' => $diff,
1395 - );
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 + }
1396 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 + );
1397 3696 }
1398 3697
1399 3698 if ( $baseline_changed ) {
1400 3699 $this->write_baseline( $baseline );
@@ -1470,30 +3769,49 @@
1470 3769 * @param string $filename File name (e.g. 'wp-config.php').
1471 3770 * @param string $content Raw file content.
1472 3771 * @return string Normalized content for hashing.
1473 3772 */
1474 - private function normalize_critical_file( $filename, $content ) {
3773 + private function normalize_critical_file( $filename, $content, $drop_all_original = false ) {
1475 3774 // Normalize line endings first (CRLF and CR to LF)
1476 3775 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
1477 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 +
1478 3792 if ( 'wp-config.php' === $filename ) {
1479 - // Remove Vigilante constants blocks (current and legacy)
3793 + // Vigilante constants blocks (current and legacy)
1480 3794 foreach ( $this->wpconfig_markers as $markers ) {
1481 - $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '\s*/s';
1482 - $content = preg_replace( $pattern, '', $content );
3795 + $content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed );
1483 3796 }
1484 3797
1485 - // Remove lines commented out by Vigilante (original constants)
1486 - $content = preg_replace(
3798 + // Lines commented out by Vigilante (original constants)
3799 + $content = preg_replace_callback(
1487 3800 '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m',
1488 - '',
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 + },
1489 3808 $content
1490 3809 );
1491 3810 } elseif ( '.htaccess' === $filename ) {
1492 - // Remove Vigilante htaccess blocks (firewall + security headers)
3811 + // Vigilante htaccess blocks (firewall + security headers)
1493 3812 foreach ( $this->htaccess_markers as $markers ) {
1494 - $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '\s*/s';
1495 - $content = preg_replace( $pattern, '', $content );
3813 + $content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed );
1496 3814 }
1497 3815 }
1498 3816
1499 3817 // Collapse multiple blank lines into one (blocks removal leaves gaps)
@@ -1502,8 +3820,639 @@
1502 3820 return trim( $content );
1503 3821 }
1504 3822
1505 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 + /**
1506 4455 * Get stored baseline hashes for critical files
1507 4456 *
1508 4457 * @return array Associative array keyed by filename.
1509 4458 */
@@ -1520,11 +4469,11 @@
1520 4469 * @param string $filename File name relative to ABSPATH (e.g. 'wp-config.php').
1521 4470 * @return bool True on success.
1522 4471 */
1523 4472 public function update_critical_file_baseline( $filename ) {
1524 - $full_path = untrailingslashit( ABSPATH ) . '/' . $filename;
4473 + $full_path = $this->critical_file_path( $filename );
1525 4474
1526 - if ( ! file_exists( $full_path ) ) {
4475 + if ( false === $full_path ) {
1527 4476 return false;
1528 4477 }
1529 4478
1530 4479 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
@@ -1534,8 +4483,36 @@
1534 4483
1535 4484 $normalized = $this->normalize_critical_file( $filename, $content );
1536 4485
1537 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 + */
1538 4515 $baseline[ $filename ] = array(
1539 4516 'hash' => md5( $normalized ),
1540 4517 'size' => strlen( $content ),
1541 4518 'content' => $this->baseline_content( $filename, $normalized ),
@@ -1553,14 +4530,13 @@
1553 4530 * @return array Updated baseline data.
1554 4531 */
1555 4532 public function regenerate_all_baselines() {
1556 4533 $baseline = array();
1557 - $root_path = untrailingslashit( ABSPATH );
1558 4534
1559 4535 foreach ( $this->critical_root_files as $filename ) {
1560 - $full_path = $root_path . '/' . $filename;
4536 + $full_path = $this->critical_file_path( $filename );
1561 4537
1562 - if ( ! file_exists( $full_path ) ) {
4538 + if ( false === $full_path ) {
1563 4539 continue;
1564 4540 }
1565 4541
1566 4542 $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
@@ -1587,10 +4563,10 @@
1587 4563 *
1588 4564 * @param bool $force_refresh Skip the cached copy and ask wp.org again.
1589 4565 * @return array|WP_Error Checksums or error.
1590 4566 */
1591 - private function get_core_checksums( $force_refresh = false ) {
1592 - $locale = get_locale();
4567 + private function get_core_checksums( $force_refresh = false, $locale = null, $english_fallback = true ) {
4568 + $locale = null === $locale ? get_locale() : $locale;
1593 4569 $version = $this->wp_version;
1594 4570
1595 4571 // Check cache first
1596 4572 $cache_key = 'vigilante_core_checksums_' . md5( $version . $locale );
@@ -1619,8 +4595,26 @@
1619 4595
1620 4596 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1621 4597
1622 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 +
1623 4617 return new WP_Error( 'no_checksums', __( 'Could not retrieve WordPress core checksums', 'vigilante' ) );
1624 4618 }
1625 4619
1626 4620 $checksums = $body['checksums'];
@@ -1636,8 +4630,52 @@
1636 4630 return $checksums;
1637 4631 }
1638 4632
1639 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 + /**
1640 4678 * Scan plugins for modifications
1641 4679 *
1642 4680 * @return array Scan results.
1643 4681 */
@@ -1655,9 +4693,10 @@
1655 4693 if ( ! function_exists( 'get_plugins' ) ) {
1656 4694 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1657 4695 }
1658 4696
1659 - $plugins = get_plugins();
4697 + $plugins = get_plugins();
4698 + $finished = array();
1660 4699
1661 4700 foreach ( $plugins as $plugin_file => $plugin_data ) {
1662 4701 // Check time limit
1663 4702 if ( $this->is_time_exceeded() ) {
@@ -1665,21 +4704,12 @@
1665 4704 }
1666 4705
1667 4706 $plugin_slug = dirname( $plugin_file );
1668 4707
1669 - // Skip single-file plugins
1670 - if ( '.' === $plugin_slug ) {
4708 + if ( ! $this->plugin_has_turn( $plugin_slug ) ) {
1671 4709 continue;
1672 4710 }
1673 4711
1674 - // Skip slugs in their post-update grace window: wp.org may still be
1675 - // publishing the new version's checksums, so a scheduled scan here
1676 - // would raise benign "modified/extra" noise. The dedicated post-update
1677 - // verifier (vigilante_fi_postupdate_verify) handles these instead.
1678 - if ( $this->in_post_update_grace( 'plugin', $plugin_slug ) ) {
1679 - continue;
1680 - }
1681 -
1682 4712 // Get checksums from WordPress.org
1683 4713 $version = $plugin_data['Version'] ?? '';
1684 4714 $checksums = $this->get_plugin_checksums( $plugin_slug, $version );
1685 4715
@@ -1684,8 +4714,22 @@
1684 4714 $checksums = $this->get_plugin_checksums( $plugin_slug, $version );
1685 4715
1686 4716 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
1687 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 +
1688 4732 $plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug;
1689 4733
1690 4734 // Check known files against checksums (only if available)
1691 4735 if ( $has_checksums ) {
@@ -1714,9 +4758,17 @@
1714 4758
1715 4759 $results['scanned']++;
1716 4760
1717 4761 if ( ! file_exists( $file_path ) ) {
1718 - continue; // Some files might not be installed
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;
1719 4771 }
1720 4772
1721 4773 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
1722 4774 $results['modified'][] = array(
@@ -1740,15 +4792,84 @@
1740 4792 $suspicious_only = ! $has_checksums; // Without checksums, only report files with suspicious code
1741 4793 $extra_results = $this->detect_extra_files( $plugin_dir, $known_files, 'plugin', $plugin_data['Name'], $suspicious_only );
1742 4794 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
1743 4795 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
4796 + $results['errors'] = array_merge( $results['errors'] ?? array(), $extra_results['errors'] ?? array() );
1744 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 + }
1745 4812 }
1746 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 +
1747 4840 return $results;
1748 4841 }
1749 4842
1750 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 + /**
1751 4872 * Whether a file on disk matches a hash WordPress.org publishes for it.
1752 4873 *
1753 4874 * The wp.org checksums JSON gives, per file, an md5 (and usually a sha256)
1754 4875 * that may be a single string OR an array of strings: a file whose content
@@ -1878,9 +4999,9 @@
1878 4999 return $response;
1879 5000 }
1880 5001
1881 5002 $status = wp_remote_retrieve_response_code( $response );
1882 - if ( 200 !== $status ) {
5003 + if ( 404 === (int) $status ) {
1883 5004 // Cache "not found" to avoid repeated requests (never during grace).
1884 5005 if ( ! $grace ) {
1885 5006 set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
1886 5007 }
@@ -1886,8 +5007,19 @@
1886 5007 }
1887 5008 return 'not_found';
1888 5009 }
1889 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 +
1890 5022 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1891 5023
1892 5024 if ( empty( $body['files'] ) ) {
1893 5025 return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
@@ -1926,9 +5058,10 @@
1926 5058 'extra' => array(),
1927 5059 'errors' => array(),
1928 5060 );
1929 5061
1930 - $themes = wp_get_themes();
5062 + $themes = wp_get_themes();
5063 + $finished = array();
1931 5064
1932 5065 foreach ( $themes as $theme_slug => $theme ) {
1933 5066 // Check time limit
1934 5067 if ( $this->is_time_exceeded() ) {
@@ -1944,8 +5077,20 @@
1944 5077 $checksums = $this->get_theme_checksums( $theme_slug, $version );
1945 5078
1946 5079 $has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums;
1947 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 +
1948 5093 $theme_dir = $theme->get_stylesheet_directory();
1949 5094
1950 5095 // Check known files against checksums (only if available)
1951 5096 if ( $has_checksums ) {
@@ -1974,8 +5119,13 @@
1974 5119
1975 5120 $results['scanned']++;
1976 5121
1977 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 + );
1978 5128 continue;
1979 5129 }
1980 5130
1981 5131 if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) {
@@ -1998,11 +5148,32 @@
1998 5148 $suspicious_only = ! $has_checksums;
1999 5149 $extra_results = $this->detect_extra_files( $theme_dir, $known_files, 'theme', $theme->get( 'Name' ), $suspicious_only );
2000 5150 $results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] );
2001 5151 $results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] );
5152 + $results['errors'] = array_merge( $results['errors'] ?? array(), $extra_results['errors'] ?? array() );
2002 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 + }
2003 5163 }
2004 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 +
2005 5176 return $results;
2006 5177 }
2007 5178
2008 5179 /**
@@ -2012,9 +5183,9 @@
2012 5183 * @param string $version Theme version.
2013 5184 * @return array|WP_Error
2014 5185 */
2015 5186 private function get_theme_checksums( $slug, $version ) {
2016 - $cache_key = 'vigilante_theme_checksums_' . md5( $slug . $version );
5187 + $cache_key = 'vigilante_theme_package_' . md5( $slug . $version );
2017 5188
2018 5189 // Bypass the cache during the post-update grace window (see plugin path).
2019 5190 $grace = $this->in_post_update_grace( 'theme', $slug );
2020 5191
@@ -2024,48 +5195,103 @@
2024 5195 return $cached;
2025 5196 }
2026 5197 }
2027 5198
2028 - $url = sprintf(
2029 - 'https://downloads.wordpress.org/theme-checksums/%s/%s.json',
2030 - $slug,
2031 - $version
2032 - );
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 + }
2033 5212
2034 - $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
5213 + if ( ! class_exists( 'ZipArchive' ) ) {
5214 + return new WP_Error( 'no_zip', __( 'the zip extension of PHP is missing on this server', 'vigilante' ) );
5215 + }
2035 5216
2036 - if ( is_wp_error( $response ) ) {
2037 - return $response;
5217 + if ( ! function_exists( 'download_url' ) ) {
5218 + require_once ABSPATH . 'wp-admin/includes/file.php';
2038 5219 }
2039 5220
2040 - $status = wp_remote_retrieve_response_code( $response );
2041 - if ( 200 !== $status ) {
2042 - // Cache "not found" to avoid repeated requests (never during grace).
2043 - if ( ! $grace ) {
2044 - set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
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' ) );
2045 5238 }
2046 - return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) );
5239 +
5240 + return $package;
2047 5241 }
2048 5242
2049 - $body = json_decode( wp_remote_retrieve_body( $response ), true );
5243 + $checksums = array();
5244 + $archive = new ZipArchive();
2050 5245
2051 - if ( empty( $body['files'] ) ) {
2052 - return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) );
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();
2053 5282 }
2054 5283
2055 - // Store the full per-file record (md5 + sha256), either of which may be a
2056 - // string or an array; hash_matches_published() handles both shapes.
2057 - $checksums = array();
2058 - foreach ( $body['files'] as $file => $data ) {
2059 - $checksums[ $file ] = array(
2060 - 'md5' => isset( $data['md5'] ) ? $data['md5'] : null,
2061 - 'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null,
2062 - );
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' ) );
2063 5288 }
2064 5289
2065 - // Cache for 24 hours (never during grace).
5290 + // The package of a published version does not change, so it is kept
5291 + // for a month (never during grace).
2066 5292 if ( ! $grace ) {
2067 - set_transient( $cache_key, $checksums, DAY_IN_SECONDS );
5293 + set_transient( $cache_key, $checksums, MONTH_IN_SECONDS );
2068 5294 }
2069 5295
2070 5296 return $checksums;
2071 5297 }
@@ -2078,36 +5304,56 @@
2078 5304 private function scan_uploads() {
2079 5305 $found = array(
2080 5306 'suspicious' => array(),
2081 5307 'extra' => array(),
5308 + 'errors' => array(),
2082 5309 );
2083 5310 $upload_dir = wp_upload_dir();
2084 5311 $base_dir = $upload_dir['basedir'];
2085 - $max_files = 10000; // Increased limit for thorough scanning
2086 5312 $files_checked = 0;
2087 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 +
2088 5321 // Executable extensions that should never be in uploads
2089 - $dangerous_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'phps' );
5322 + $dangerous_extensions = self::php_extensions();
2090 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 +
2091 5330 if ( ! is_dir( $base_dir ) ) {
2092 5331 return $found;
2093 5332 }
2094 5333
2095 5334 try {
2096 - $iterator = new RecursiveIteratorIterator(
2097 - new RecursiveDirectoryIterator( $base_dir, RecursiveDirectoryIterator::SKIP_DOTS ),
2098 - RecursiveIteratorIterator::LEAVES_ONLY
2099 - );
2100 -
2101 - foreach ( $iterator as $file ) {
5335 + foreach ( self::walk_folder( $base_dir ) as $file ) {
2102 5336 // Check global time limit
2103 5337 if ( $this->is_time_exceeded() ) {
2104 5338 break;
2105 5339 }
2106 5340
2107 - // Check file limit
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)
2108 5349 $files_checked++;
2109 - if ( $files_checked > $max_files ) {
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 + );
2110 5356 break;
2111 5357 }
2112 5358
2113 5359 $file_path = $file->getPathname();
@@ -2132,15 +5378,12 @@
2132 5378
2133 5379 $reason = __( 'PHP file found in uploads directory', 'vigilante' );
2134 5380
2135 5381 // Scan content for specific suspicious patterns
2136 - if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
2137 - $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2138 - $pattern = $this->detect_suspicious_pattern( $content );
2139 - if ( $pattern ) {
2140 - /* translators: %s: Suspicious pattern found */
2141 - $reason = sprintf( __( 'PHP in uploads with suspicious code: %s', 'vigilante' ), $pattern );
2142 - }
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 );
2143 5386 }
2144 5387
2145 5388 $found['suspicious'][] = array(
2146 5389 'file' => $relative,
@@ -2150,9 +5393,12 @@
2150 5393 continue;
2151 5394 }
2152 5395
2153 5396 // 2. Check for double extensions (image.php.jpg, file.phtml.png)
2154 - if ( preg_match( '/\.(' . implode( '|', $dangerous_extensions ) . ')\.[a-z]{2,4}$/i', $basename ) ) {
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 ) ) {
2155 5401 $found['suspicious'][] = array(
2156 5402 'file' => $relative,
2157 5403 'type' => 'double_extension',
2158 5404 'reason' => __( 'Double extension detected (possible disguised executable)', 'vigilante' ),
@@ -2159,17 +5405,41 @@
2159 5405 );
2160 5406 continue;
2161 5407 }
2162 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 +
2163 5429 // 3. Check for .htaccess files in uploads
2164 - // Read content to classify: dangerous rules = suspicious, protective rules = extra
5430 + // Read content to classify (see classify_htaccess_in_uploads())
2165 5431 if ( '.htaccess' === $basename ) {
2166 5432 $htaccess_result = $this->classify_htaccess_in_uploads( $file_path, $relative );
2167 - $found[ $htaccess_result['category'] ][] = $htaccess_result['item'];
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 + }
2168 5438 }
2169 5439 }
2170 5440 } catch ( Exception $e ) {
2171 - // Ignore iterator errors
5441 + $found['errors'][] = self::unreadable_folder_error( $base_dir );
2172 5442 }
2173 5443
2174 5444 return $found;
2175 5445 }
@@ -2176,94 +5446,515 @@
2176 5446
2177 5447 /**
2178 5448 * Classify a .htaccess file found in uploads directory
2179 5449 *
2180 - * Reads the file content to determine if it contains dangerous rules
2181 - * (enabling PHP execution, rewriting to executables) or protective rules
2182 - * (deny access, disable indexes). Dangerous = suspicious, protective = extra.
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.
2183 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 + *
2184 5463 * @param string $file_path Absolute file path.
2185 5464 * @param string $relative Relative file path for display.
2186 - * @return array Array with 'category' ('suspicious' or 'extra') and 'item' data.
5465 + * @return array Array with 'category' ('suspicious', 'extra' or 'none') and 'item' data.
2187 5466 */
2188 5467 private function classify_htaccess_in_uploads( $file_path, $relative ) {
2189 - $content = '';
5468 + $content = false;
2190 5469
2191 5470 if ( filesize( $file_path ) < 65536 ) { // Only read files < 64KB
2192 5471 $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2193 5472 }
2194 5473
2195 - // If we can't read it or it's empty, treat as suspicious (unknown)
2196 - if ( empty( trim( $content ) ) ) {
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 +
2197 5483 return array(
2198 5484 'category' => 'suspicious',
2199 - 'item' => array(
2200 - 'file' => $relative,
2201 - 'type' => 'htaccess_in_uploads',
2202 - 'reason' => __( '.htaccess file in uploads directory (empty or unreadable)', 'vigilante' ),
2203 - ),
5485 + 'item' => $item,
2204 5486 );
2205 5487 }
2206 5488
2207 - // Dangerous patterns: rules that enable code execution or rewrite to executables
2208 - $dangerous_patterns = array(
2209 - '/AddHandler\s+.*(php|cgi|pl|py)/i' => 'AddHandler enabling script execution',
2210 - '/AddType\s+application\/x-httpd-php/i' => 'AddType enabling PHP execution',
2211 - '/SetHandler\s+.*(php|cgi)/i' => 'SetHandler enabling script execution',
2212 - '/php_flag\s+engine\s+on/i' => 'PHP engine enabled',
2213 - '/php_admin_flag\s+engine\s+on/i' => 'PHP admin engine enabled',
2214 - '/RewriteRule\s+.*\.(php|phtml|phar)/i' => 'Rewrite rule targeting PHP files',
2215 - '/auto_prepend_file/i' => 'auto_prepend_file directive',
2216 - '/auto_append_file/i' => 'auto_append_file directive',
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' ),
2217 5523 );
2218 5524
2219 - foreach ( $dangerous_patterns as $pattern => $label ) {
2220 - if ( preg_match( $pattern, $content ) ) {
2221 - return array(
2222 - 'category' => 'suspicious',
2223 - 'item' => array(
2224 - 'file' => $relative,
2225 - 'type' => 'htaccess_in_uploads',
2226 - /* translators: %s: Dangerous rule description */
2227 - 'reason' => sprintf( __( '.htaccess with dangerous rule: %s', 'vigilante' ), $label ),
2228 - ),
2229 - );
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 ) );
2230 5585 }
5586 +
5587 + return true === self::extension_pattern_names_php( trim( $args, '"\'' ), $regex, false ) ? 'files-php' : 'files';
2231 5588 }
2232 5589
2233 - // Identify what protective/benign rules it contains for informational display
2234 - $found_rules = array();
5590 + return 'condition';
5591 + }
2235 5592
2236 - $benign_patterns = array(
2237 - '/Deny\s+from\s+all/i' => 'Deny from all',
2238 - '/Require\s+all\s+denied/i' => 'Require all denied',
2239 - '/Options\s+.*-Indexes/i' => 'Options -Indexes',
2240 - '/Header\s+set/i' => 'Header rules',
2241 - '/ExpiresActive/i' => 'Expires/cache rules',
2242 - '/RewriteEngine/i' => 'Rewrite rules',
2243 - '/FilesMatch/i' => 'FilesMatch rules',
2244 - '/ForceType\s+application\/octet/i' => 'ForceType (force download)',
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,
2245 5733 );
2246 5734
2247 - foreach ( $benign_patterns as $pattern => $label ) {
2248 - if ( preg_match( $pattern, $content ) ) {
2249 - $found_rules[] = $label;
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;
2250 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 + }
2251 5950 }
2252 5951
2253 - $rules_summary = ! empty( $found_rules )
2254 - ? implode( ', ', $found_rules )
2255 - : __( 'Custom rules', 'vigilante' );
5952 + if ( $cgi_handler && ( $execcgi_on || ! $execcgi_off ) ) {
5953 + $facts['runs']['cgi'] = true;
5954 + }
2256 5955
2257 - return array(
2258 - 'category' => 'extra',
2259 - 'item' => array(
2260 - 'file' => $relative,
2261 - 'type' => 'htaccess_in_uploads',
2262 - /* translators: %s: Summary of rules found in the .htaccess file */
2263 - 'reason' => sprintf( __( '.htaccess in uploads (likely from plugin). Contains: %s', 'vigilante' ), $rules_summary ),
2264 - ),
2265 - );
5956 + return $facts;
2266 5957 }
2267 5958
2268 5959 /**
2269 5960 * Check content for suspicious patterns
@@ -2307,25 +5998,45 @@
2307 5998 foreach ( $patterns_data['standard_patterns'] as $encoded_needle => $label ) {
2308 5999 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding pattern definitions, not user input.
2309 6000 $needle = base64_decode( $encoded_needle );
2310 6001 if ( $this->needle_present( $content, $needle ) ) {
2311 - return $label;
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 );
2312 6005 }
2313 6006 }
2314 6007
2315 - // Check for preg_replace with /e modifier (code execution)
2316 - if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
2317 - return 'preg_replace /e modifier';
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' );
2318 6025 }
2319 6026
2320 - // Check for long hex-encoded strings (obfuscated payloads)
2321 - if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
2322 - return 'hex-encoded string';
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' );
2323 6034 }
2324 6035
2325 - // Check for heavily concatenated chr() calls (char-by-char obfuscation)
2326 - if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
2327 - return 'chr() concatenation obfuscation';
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' );
2328 6039 }
2329 6040
2330 6041 return false;
2331 6042 }
@@ -2362,31 +6073,110 @@
2362 6073 $as = $fragments['as'] ?? '';
2363 6074 $ss = $fragments['ss'] ?? '';
2364 6075 $cf = $fragments['cf'] ?? '';
2365 6076
2366 - // Obfuscation combos: dangerous function wrapping decoded content
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.
2367 6086 $obfuscation_combos = array(
2368 - '/' . $ev . '\s*\(\s*' . $b6 . '\s*\(/i' => $ev . '(' . $b6 . '())',
2369 - '/' . $ev . '\s*\(\s*' . $gz . '\s*\(/i' => $ev . '(' . $gz . '())',
2370 - '/' . $ev . '\s*\(\s*' . $gu . '\s*\(/i' => $ev . '(' . $gu . '())',
2371 - '/' . $ev . '\s*\(\s*' . $sr . '\s*\(/i' => $ev . '(' . $sr . '())',
2372 - '/' . $ev . '\s*\(\s*' . $hb . '\s*\(/i' => $ev . '(' . $hb . '())',
2373 - '/' . $as . '\s*\(\s*' . $b6 . '\s*\(/i' => $as . '(' . $b6 . '())',
2374 - '/' . $ev . '\s*\(\s*\$[a-z_]+\s*\(/i' => $ev . '($variable())',
2375 - '/' . $ev . '\s*\(\s*' . $ss . '\s*\(/i' => $ev . '(' . $ss . '())',
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 . '()',
2376 6095 );
2377 6096
2378 6097 foreach ( $obfuscation_combos as $regex => $label ) {
2379 - if ( preg_match( $regex, $content ) ) {
6098 + if ( $this->found( $regex, $content ) ) {
2380 6099 return $label;
2381 6100 }
2382 6101 }
2383 6102
2384 - // Deprecated dynamic function constructor, nearly always malicious in modern code
2385 - if ( ! empty( $cf ) && $this->needle_present( $content, $cf ) ) {
2386 - return $cf . ')';
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 + }
2387 6114 }
2388 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 + // The dynamic function constructor, further down, reuses the copy.
6127 + $code = null;
6128 +
6129 + if ( $this->found( '/\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\b/', $content ) ) {
6130 + $code = $this->blank_comments( $content );
6131 +
6132 + // When PHP cannot be asked what is a comment, the file is
6133 + // searched as it is: a comment may then be reported, and nothing
6134 + // that runs is left out.
6135 + $subject = false === $code ? $content : $code;
6136 +
6137 + if ( ! empty( $runners ) ) {
6138 + $names = implode( '|', array_map( 'preg_quote', $runners ) );
6139 + // What the visitor sends, when it is not just being asked about
6140 + // with isset() or empty(). The path of an uploaded file is not in
6141 + // the list: handing it to a command is how images get processed.
6142 + $from_request = '(?<!isset\()(?<!isset\( )(?<!empty\()(?<!empty\( )(?:\$_(?:GET|POST|REQUEST|COOKIE)\b|\$_SERVER\s*\[\s*[\'"]HTTP_)';
6143 +
6144 + // The possessive quantifiers keep a file made of spaces from
6145 + // costing a try per space.
6146 + if ( $this->found( '/(?<![a-z0-9_>:$])(?:' . $names . ')\s*+\(\s*+[^;\n]{0,80}?' . $from_request . '/i', $subject ) ) {
6147 + return __( 'data from the request is run as code or as a command', 'vigilante' );
6148 + }
6149 + }
6150 +
6151 + if ( $this->found( '/\$_(?:GET|POST|REQUEST|COOKIE)\s*+\[[^\]\n]{1,60}\]\s*+\(/', $subject ) ) {
6152 + return __( 'a function chosen by the request is called', 'vigilante' );
6153 + }
6154 + }
6155 +
6156 + if ( $this->found_in_code( '/(?<![a-z0-9_>$])(?:include|require)(?:_once)?\s*+(?:\(\s*+)?[\'"]https?:\/\//i', $content, $code ) ) {
6157 + return __( 'a file from another server is included', 'vigilante' );
6158 + }
6159 +
6160 + // Deprecated dynamic function constructor, nearly always malicious in
6161 + // modern code. It is a call, so it is looked for in the code and not
6162 + // in the comments, like the three above: an old call left commented
6163 + // out runs nothing, and plugins that are not on WordPress.org keep
6164 + // them. Only the files that name it are prepared that way, and the
6165 + // search goes straight to the copy without comments, so a comment
6166 + // between the name and its parenthesis hides nothing either.
6167 + if ( ! empty( $cf ) && false !== stripos( $content, rtrim( $cf, '(' ) ) ) {
6168 + if ( null === $code ) {
6169 + $code = $this->blank_comments( $content );
6170 + }
6171 +
6172 + // When PHP cannot be asked what is a comment, the match stands.
6173 + if ( $this->needle_present( false === $code ? $content : $code, $cf ) ) {
6174 + // The name alone, for the same reason as the labels above.
6175 + return rtrim( $cf, '(' );
6176 + }
6177 + }
6178 +
2389 6179 // Remote fetch piped into unserialize: a PHP object-injection / supply-chain
2390 6180 // vector seen in trojanized or nulled plugins (download a payload from a
2391 6181 // remote URL and unserialize it). Requires a REAL unserialize() call
2392 6182 // (not maybe_unserialize() / igbinary_unserialize(), which are common and
@@ -2394,11 +6184,21 @@
2394 6184 // checked that both strings appeared somewhere in the file, which
2395 6185 // false-positived on legitimate code using both in unrelated methods
2396 6186 // (e.g. a theme reading a transient with maybe_unserialize() while
2397 6187 // fetching its public IP with wp_remote_get()).
2398 - $us = $fragments['us'] ?? '';
2399 - if ( '' !== $us ) {
2400 - $us_regex = '/(?<![a-z0-9_])' . preg_quote( $us, '/' ) . '\s*\(/i';
6188 + //
6189 + // The search runs on a copy of the file in lower case, with
6190 + // expressions that tell the case apart, and only when the file names
6191 + // the function at all. PCRE without its compiler looks ahead for the
6192 + // first letter of an expression in both cases before each try, and
6193 + // when one of the two is not in the file that look runs to the end of
6194 + // it. pattern_near() asks once per match, so a file with thousands of
6195 + // them cost thousands of passes (26 seconds for 8 MB, measured). With
6196 + // one case to look for, each look stops at the next candidate.
6197 + $us = self::lower_ascii( $fragments['us'] ?? '' );
6198 + if ( '' !== $us && false !== stripos( $content, $us ) ) {
6199 + $lower = self::lower_ascii( $content );
6200 + $us_regex = '/(?<![a-z0-9_])' . preg_quote( $us, '/' ) . '\s*+\(/';
2401 6201 $remote_fetchers = array(
2402 6202 $fragments['wr'] ?? '', // wp_remote_get
2403 6203 $fragments['rb'] ?? '', // wp_remote_retrieve_body
2404 6204 $fragments['ce'] ?? '', // curl_exec
@@ -2406,11 +6206,12 @@
2406 6206 foreach ( $remote_fetchers as $rf ) {
2407 6207 if ( '' === $rf ) {
2408 6208 continue;
2409 6209 }
2410 - $rf_regex = '/(?<![a-z0-9_])' . preg_quote( $rf, '/' ) . '\s*\(/i';
2411 - if ( $this->pattern_near( $content, $us_regex, $rf_regex, 600 ) ) {
2412 - return $rf . '() + ' . $us . '() remote deserialization';
6210 + $rf_regex = '/(?<![a-z0-9_])' . preg_quote( self::lower_ascii( $rf ), '/' ) . '\s*+\(/';
6211 + if ( $this->pattern_near( $lower, $us_regex, $rf_regex, 600 ) ) {
6212 + /* translators: 1: Function that downloads, 2: Function that unserializes */
6213 + return sprintf( __( 'data downloaded from another server is unserialized (%1$s and %2$s)', 'vigilante' ), $rf, $us );
2413 6214 }
2414 6215 }
2415 6216
2416 6217 // file_get_contents() is treated separately from the fetchers
@@ -2423,51 +6224,106 @@
2423 6224 // argument is a URL, so the combo additionally requires a
2424 6225 // remote-scheme literal near the call before it fires.
2425 6226 $fg = $fragments['fg'] ?? '';
2426 6227 if ( '' !== $fg ) {
2427 - $fg_regex = '/(?<![a-z0-9_])' . preg_quote( $fg, '/' ) . '\s*\(/i';
2428 - $scheme_regex = '/(?:https?|ftps?):\/\/|php:\/\/input/i';
6228 + $fg_regex = '/(?<![a-z0-9_])' . preg_quote( self::lower_ascii( $fg ), '/' ) . '\s*+\(/';
6229 + $scheme_regex = '/(?:https?|ftps?):\/\/|php:\/\/input/';
2429 6230
2430 - if ( $this->pattern_near( $content, $us_regex, $fg_regex, 600 )
2431 - && $this->pattern_near( $content, $fg_regex, $scheme_regex, 600 ) ) {
2432 - return $fg . '() + ' . $us . '() remote deserialization';
6231 + if ( $this->pattern_near( $lower, $us_regex, $fg_regex, 600 )
6232 + && $this->pattern_near( $lower, $fg_regex, $scheme_regex, 600 ) ) {
6233 + /* translators: 1: Function that downloads, 2: Function that unserializes */
6234 + return sprintf( __( 'data downloaded from another server is unserialized (%1$s and %2$s)', 'vigilante' ), $fg, $us );
2433 6235 }
2434 6236 }
2435 6237 }
2436 6238
2437 - // preg_replace with /e modifier (arbitrary code execution, deprecated)
2438 - if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/e[\'"]/i', $content ) ) {
2439 - return 'preg_replace /e modifier';
6239 + $common = $this->detect_common_pattern( $content );
6240 + if ( $common ) {
6241 + return $common;
2440 6242 }
2441 6243
2442 - // Long hex-encoded strings (obfuscated payloads)
2443 - if ( preg_match( '/\\\\x[0-9a-f]{2}(\\\\x[0-9a-f]{2}){10,}/i', $content ) ) {
2444 - return 'hex-encoded string';
6244 + // Dangerous function names built from string concatenation: a variable
6245 + // given a name in pieces and then called. The assignments are taken
6246 + // one at a time and the calls are looked up in one pass. Collecting
6247 + // every assignment of the file first, and then searching the whole
6248 + // file again for each one, took memory and time that grew with the
6249 + // square of the file.
6250 + $split = array();
6251 + $capped = false;
6252 + $offset = 0;
6253 +
6254 + while ( true ) {
6255 + // A variable, "=" and a quoted piece followed by a dot: where a
6256 + // string put together from pieces starts.
6257 + $start = $this->next_match( '/\$([a-z_]\w*)\s*+=\s*+(?=["\'][a-z0-9_]*+["\']\s*+\.)/i', $content, $offset );
6258 +
6259 + if ( null === $start ) {
6260 + break;
6261 + }
6262 +
6263 + // The pieces are taken one by one, however many there are: a name
6264 + // can be padded with as many empty ones as its author likes, and
6265 + // asking PCRE for the whole chain at once is what made it give up.
6266 + $position = $start[0][1] + strlen( $start[0][0] );
6267 + $combined = '';
6268 + $closed = false;
6269 +
6270 + while ( true ) {
6271 + $piece = $this->next_match( '/\G["\']([a-z0-9_]*+)["\']\s*+(\.\s*+|;)?+/i', $content, $position );
6272 +
6273 + if ( null === $piece ) {
6274 + break;
6275 + }
6276 +
6277 + // No name on the list is this long: past it, the letters are
6278 + // not kept, and the chain is still walked to its end.
6279 + if ( strlen( $combined ) <= 64 ) {
6280 + $combined .= self::lower_ascii( $piece[1][0] );
6281 + }
6282 +
6283 + $position = $piece[0][1] + strlen( $piece[0][0] );
6284 + $after = isset( $piece[2] ) && $piece[2][1] >= 0 ? $piece[2][0] : '';
6285 +
6286 + if ( '' === $after || ';' === $after ) {
6287 + $closed = ';' === $after;
6288 + break;
6289 + }
6290 + }
6291 +
6292 + $offset = max( $position, $start[0][1] + 1 );
6293 +
6294 + if ( $closed && in_array( $combined, $dangerous_names, true ) && ! isset( $split[ $start[1][0] ] ) ) {
6295 + $split[ $start[1][0] ] = $combined;
6296 +
6297 + if ( count( $split ) >= 500 ) {
6298 + $capped = true;
6299 + break;
6300 + }
6301 + }
2445 6302 }
2446 6303
2447 - // Heavily concatenated chr() calls (char-by-char obfuscation)
2448 - if ( preg_match( '/chr\s*\(\s*\d+\s*\)\s*\.\s*chr\s*\(\s*\d+\s*\)\s*\.\s*chr/i', $content ) ) {
2449 - return 'chr() concatenation obfuscation';
2450 - }
6304 + if ( ! empty( $split ) ) {
6305 + $offset = 0;
2451 6306
2452 - // Detect dangerous function names built from string concatenation
2453 - if ( preg_match_all( '/\$([a-z_]\w*)\s*=\s*((?:["\'][a-z0-9_]*["\']\s*\.\s*)+["\'][a-z0-9_]*["\'])\s*;/i', $content, $matches, PREG_SET_ORDER ) ) {
2454 - $dangerous_names = array();
2455 - if ( ! empty( $patterns_data['dangerous_names'] ) ) {
2456 - foreach ( $patterns_data['dangerous_names'] as $encoded_name ) {
2457 - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
2458 - $dangerous_names[] = base64_decode( $encoded_name );
6307 + while ( true ) {
6308 + $call = $this->next_match( '/\$([a-z_]\w*)\s*+\(/i', $content, $offset );
6309 +
6310 + if ( null === $call ) {
6311 + break;
2459 6312 }
6313 +
6314 + if ( isset( $split[ $call[1][0] ] ) ) {
6315 + /* translators: %s: Function name */
6316 + return sprintf( __( 'call to %s() with its name split in pieces', 'vigilante' ), $split[ $call[1][0] ] );
6317 + }
6318 +
6319 + $offset = $call[0][1] + strlen( $call[0][0] );
2460 6320 }
2461 6321
2462 - foreach ( $matches as $match ) {
2463 - $combined = strtolower( preg_replace( '/["\'\s\.]/', '', $match[2] ) );
2464 - if ( in_array( $combined, $dangerous_names, true ) ) {
2465 - $var_pattern = '/\$' . preg_quote( $match[1], '/' ) . '\s*\(/';
2466 - if ( preg_match( $var_pattern, $content ) ) {
2467 - return 'obfuscated ' . $combined . '() call';
2468 - }
2469 - }
6322 + // More names in pieces than a file has any reason to hold, and
6323 + // not every one was followed: not searched, rather than clean.
6324 + if ( $capped ) {
6325 + $this->search_failed = true;
2470 6326 }
2471 6327 }
2472 6328
2473 6329 return false;
@@ -2493,9 +6349,9 @@
2493 6349 return false;
2494 6350 }
2495 6351 if ( preg_match( '/^[a-z_][a-z0-9_]*\($/i', $needle ) ) {
2496 6352 $fn = rtrim( $needle, '(' );
2497 - return (bool) preg_match( '/(?<![a-z0-9_])' . preg_quote( $fn, '/' ) . '\s*\(/i', $content );
6353 + return $this->found( '/(?<![a-z0-9_])' . preg_quote( $fn, '/' ) . '\s*+\(/i', $content );
2498 6354 }
2499 6355 return stripos( $content, $needle ) !== false;
2500 6356 }
2501 6357
@@ -2513,25 +6369,266 @@
2513 6369 * @param int $window Maximum byte distance between a match of each.
2514 6370 * @return bool
2515 6371 */
2516 6372 private function pattern_near( $content, $regex_a, $regex_b, $window ) {
2517 - if ( ! preg_match_all( $regex_a, $content, $m_a, PREG_OFFSET_CAPTURE ) ) {
6373 + // Both lists come in order, so they are walked together: the match
6374 + // that is behind moves on, and no pair is compared twice. Every match
6375 + // of one used to be compared with every match of the other.
6376 + $a = $this->next_match( $regex_a, $content, 0 );
6377 + $b = $this->next_match( $regex_b, $content, 0 );
6378 +
6379 + while ( null !== $a && null !== $b ) {
6380 + if ( abs( $a[0][1] - $b[0][1] ) <= $window ) {
6381 + return true;
6382 + }
6383 +
6384 + if ( $a[0][1] < $b[0][1] ) {
6385 + $a = $this->next_match( $regex_a, $content, $a[0][1] + 1 );
6386 + } else {
6387 + $b = $this->next_match( $regex_b, $content, $b[0][1] + 1 );
6388 + }
6389 + }
6390 +
6391 + return false;
6392 + }
6393 +
6394 + /**
6395 + * A text with its capital letters from A to Z in lower case, and nothing else
6396 + *
6397 + * strtolower() follows the language of the server until PHP 8.2, and in
6398 + * Turkish a capital I does not become an i: a function name written in
6399 + * capitals would not be found there.
6400 + *
6401 + * @param string $text Text.
6402 + * @return string
6403 + */
6404 + private static function lower_ascii( $text ) {
6405 + return strtr( (string) $text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz' );
6406 + }
6407 +
6408 + /**
6409 + * Whether an expression matches, without taking a search that gave up for a "no"
6410 + *
6411 + * preg_match() answers false, and not 0, when PCRE stops before the end
6412 + * because a long run used up the stack of its compiler or its
6413 + * backtracking limit. Read as "nothing found", that is a file that passes
6414 + * as clean without having been searched. Measured on what was published
6415 + * until now: a hexadecimal string of some 12,000 characters made the
6416 + * expression that looks for hexadecimal strings give up. Every expression
6417 + * of the search goes through here or through next_match(), and a file on
6418 + * which one of them gave up is reported as not searched (search_file()).
6419 + *
6420 + * @param string $regex Expression, with delimiters and flags.
6421 + * @param string $content Text to search.
6422 + * @return bool
6423 + */
6424 + private function found( $regex, $content ) {
6425 + $result = preg_match( $regex, $content );
6426 +
6427 + if ( false === $result ) {
6428 + $this->search_failed = true;
6429 + }
6430 +
6431 + return 1 === $result;
6432 + }
6433 +
6434 + /**
6435 + * The next match of an expression from a position on
6436 + *
6437 + * For the checks that walk every match. Asked for one at a time they take
6438 + * no memory to speak of, where preg_match_all() held every match of the
6439 + * file at once.
6440 + *
6441 + * @param string $regex Expression, with delimiters and flags.
6442 + * @param string $content Text to search.
6443 + * @param int $offset Where to start.
6444 + * @return array|null The match as PREG_OFFSET_CAPTURE gives it, or null.
6445 + */
6446 + private function next_match( $regex, $content, $offset ) {
6447 + $match = array();
6448 + $result = preg_match( $regex, $content, $match, PREG_OFFSET_CAPTURE, $offset );
6449 +
6450 + if ( false === $result ) {
6451 + $this->search_failed = true;
6452 + }
6453 +
6454 + return 1 === $result ? $match : null;
6455 + }
6456 +
6457 + /**
6458 + * Whether an expression matches in the code of a file and not only in its comments
6459 + *
6460 + * @param string $regex Expression, with delimiters and flags.
6461 + * @param string $content File content.
6462 + * @param string|false|null $code The content with its comments blanked. Worked
6463 + * out here the first time it is needed and kept
6464 + * by the caller; false when it cannot be.
6465 + * @return bool
6466 + */
6467 + private function found_in_code( $regex, $content, &$code ) {
6468 + if ( ! $this->found( $regex, $content ) ) {
2518 6469 return false;
2519 6470 }
2520 - if ( ! preg_match_all( $regex_b, $content, $m_b, PREG_OFFSET_CAPTURE ) ) {
6471 +
6472 + if ( null === $code ) {
6473 + $code = $this->blank_comments( $content );
6474 + }
6475 +
6476 + // When PHP cannot be asked what is a comment, the match stands.
6477 + return false === $code || $this->found( $regex, $code );
6478 + }
6479 +
6480 + /**
6481 + * The content of a PHP file with its comments turned into blank space
6482 + *
6483 + * PHP itself says what is a comment (token_get_all()), so nothing that
6484 + * runs can be taken for one: a "//" comment ends at "?>", a string that
6485 + * holds "/*" opens nothing, and so on, without a second reader of the
6486 + * language here to get any of it wrong. Each comment is replaced by as
6487 + * many spaces as it had characters, line breaks kept, so the result is as
6488 + * long as the original and what was on one line stays on one line.
6489 + *
6490 + * Three cases are left as they are, and then the match stands:
6491 + *
6492 + * - A file with a "<?" that is not "<?php" or "<?=". Whether PHP reads
6493 + * that as an opening tag depends on short_open_tag, which may not be
6494 + * set the same for the PHP that runs this scan as for the one that
6495 + * serves the site, and with it changes where the code starts and so
6496 + * what is a comment.
6497 + * - A comment that starts with "#[". PHP 8 reads that as an attribute and
6498 + * runs what follows it on the line; PHP 7 reads the whole line as a
6499 + * comment.
6500 + * - A file whose tokens would not fit in the memory that is left, or
6501 + * would take more than 64 MB. PHP returns one array per token, up to
6502 + * 450 bytes each, and a file can be written so that every byte is one:
6503 + * 400 KB of that took 180 MB with PHP 7.4 (measured). How many tokens
6504 + * there can be is counted first, without making them.
6505 + * - Any file, when PHP is set to read its source in another encoding
6506 + * (zend.multibyte). In Shift JIS, Big5 or GBK the second byte of a
6507 + * character can be a backslash, so where a string ends is not where
6508 + * it ends for a reader that goes byte by byte.
6509 + *
6510 + * @param string $content File content.
6511 + * @return string|false The content without its comments, or false when it
6512 + * cannot be told what is a comment.
6513 + */
6514 + private function blank_comments( $content ) {
6515 + if ( ! function_exists( 'token_get_all' ) || ini_get( 'zend.multibyte' ) || preg_match( '/<\?(?!php[ \t\r\n]|=)/i', $content ) ) {
2521 6516 return false;
2522 6517 }
2523 - foreach ( $m_a[0] as $a ) {
2524 - foreach ( $m_b[0] as $b ) {
2525 - if ( abs( $a[1] - $b[1] ) <= $window ) {
2526 - return true;
2527 - }
6518 +
6519 + // No more tokens than this: each character that is not part of a
6520 + // word can be one, and so can each run of digits and each word.
6521 + $words = 0;
6522 + $others = preg_replace( '/[a-z_\x80-\xff][a-z0-9_\x80-\xff]*+|[0-9]++/i', '', $content, -1, $words );
6523 +
6524 + if ( null === $others ) {
6525 + return false;
6526 + }
6527 +
6528 + $needed = 450 * ( strlen( $others ) + $words + 2 ) + strlen( $content );
6529 + $limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) );
6530 + unset( $others );
6531 +
6532 + if ( $needed > 64 * MB_IN_BYTES || ( $limit > 0 && ( $limit - memory_get_usage() ) < $needed ) ) {
6533 + return false;
6534 + }
6535 +
6536 + $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.
6537 + $code = '';
6538 + $length = 0;
6539 +
6540 + foreach ( $tokens as $token ) {
6541 + $text = is_array( $token ) ? $token[1] : $token;
6542 + $length += strlen( $text );
6543 +
6544 + if ( is_array( $token ) && ( T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0] ) && 0 !== strpos( $text, '#[' ) ) {
6545 + $text = preg_replace( '/[^\n]/', ' ', $text );
2528 6546 }
6547 +
6548 + $code .= $text;
2529 6549 }
2530 - return false;
6550 +
6551 + // The pieces have to add up to the file. If they do not, the
6552 + // positions cannot be trusted and neither can the result.
6553 + if ( strlen( $content ) !== $length || strlen( $code ) !== $length ) {
6554 + return false;
6555 + }
6556 +
6557 + return $code;
2531 6558 }
2532 6559
2533 6560 /**
6561 + * Search one file for hidden code
6562 + *
6563 + * The one place where a file is read for that. Each scan used to read it
6564 + * by itself, and only when it was under 512,000 bytes: a larger file was
6565 + * not opened, and where the file was not going to be listed anyway (a
6566 + * plugin or a theme with no official copy) that was the same as calling
6567 + * it clean. Padding a file was enough to keep it out of the results.
6568 + *
6569 + * @param string $file_path Absolute path.
6570 + * @param bool $strict Use strict mode (see detect_suspicious_pattern()).
6571 + * @return string|false|null The pattern found, false when the file was
6572 + * searched and has none, or null when it could
6573 + * not be searched: it is larger than
6574 + * MAX_SEARCHED_BYTES, it cannot be read, or an
6575 + * expression gave up on it. The caller reports
6576 + * that instead of taking it for clean.
6577 + */
6578 + private function search_file( $file_path, $strict = false ) {
6579 + if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) {
6580 + return null;
6581 + }
6582 +
6583 + $size = filesize( $file_path );
6584 +
6585 + if ( false === $size || $size > self::MAX_SEARCHED_BYTES ) {
6586 + return null;
6587 + }
6588 +
6589 + // One byte past the cap and no more, whatever the size said: a file
6590 + // that grew since it was measured is not read whole.
6591 + $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.
6592 +
6593 + if ( false === $content || strlen( $content ) > self::MAX_SEARCHED_BYTES ) {
6594 + return null;
6595 + }
6596 +
6597 + $this->search_failed = false;
6598 + $pattern = $this->detect_suspicious_pattern( $content, $strict );
6599 +
6600 + if ( $pattern ) {
6601 + return $pattern;
6602 + }
6603 +
6604 + return $this->search_failed ? null : false;
6605 + }
6606 +
6607 + /**
6608 + * The finding for a PHP file with no official copy that could not be searched
6609 + *
6610 + * @param string $file_path Absolute path.
6611 + * @param string $relative Path for display.
6612 + * @return array
6613 + */
6614 + private function not_searched_item( $file_path, $relative ) {
6615 + $size = is_file( $file_path ) ? filesize( $file_path ) : false;
6616 + $reason = __( 'PHP file that could not be searched for hidden code', 'vigilante' );
6617 +
6618 + if ( false !== $size && $size > self::MAX_SEARCHED_BYTES ) {
6619 + /* translators: %s: File size, such as 12 MB */
6620 + $reason = sprintf( __( 'PHP file of %s, too large to be searched for hidden code', 'vigilante' ), size_format( $size ) );
6621 + }
6622 +
6623 + return array(
6624 + 'file' => $relative,
6625 + 'type' => 'not_searched',
6626 + 'reason' => $reason,
6627 + );
6628 + }
6629 +
6630 + /**
2534 6631 * Whether a file is a text file worth normalizing before the fallback hash
2535 6632 * comparison in hash_matches_published().
2536 6633 *
2537 6634 * @param string $file_path Absolute path.
@@ -2615,14 +6712,22 @@
2615 6712 private function detect_extra_files( $directory, $checksums, $type, $name, $suspicious_only = false ) {
2616 6713 $found = array(
2617 6714 'suspicious' => array(),
2618 6715 'extra' => array(),
6716 + 'errors' => array(),
6717 + 'php_files' => 0,
2619 6718 );
2620 - $max_extra = 50; // Limit to prevent timeout on large plugins
2621 - $count = 0;
6719 + // Every PHP file is examined. Until now the walk stopped after 50, which
6720 + // in a plugin or theme with no official copy meant the first 50 files
6721 + // of the folder and nothing else. Only the list of plain extra files is
6722 + // bounded, so a folder full of generated PHP cannot fill the results;
6723 + // hidden code is reported wherever it is.
6724 + $max_listed = 500;
6725 + $listed = 0;
6726 + $truncated = false;
2622 6727
2623 6728 // Only check PHP files for performance
2624 - $php_extensions = array( 'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar' );
6729 + $php_extensions = self::php_extensions();
2625 6730
2626 6731 // Pre-normalize the checksum keys once so path-shape differences
2627 6732 // (Windows backslashes, a leading "./", doubled slashes) don't make a
2628 6733 // known file look "extra" and get scanned or flagged. Case is preserved.
@@ -2631,21 +6736,39 @@
2631 6736 $known_normalized[ $this->normalize_rel_path( $known_file ) ] = true;
2632 6737 }
2633 6738
2634 6739 try {
2635 - $iterator = new RecursiveIteratorIterator(
2636 - new RecursiveDirectoryIterator( $directory, RecursiveDirectoryIterator::SKIP_DOTS ),
2637 - RecursiveIteratorIterator::LEAVES_ONLY
2638 - );
2639 -
2640 - foreach ( $iterator as $file ) {
2641 - if ( $this->is_time_exceeded() || $count >= $max_extra ) {
6740 + foreach ( self::walk_folder( $directory ) as $file ) {
6741 + if ( $this->is_time_exceeded() ) {
2642 6742 break;
2643 6743 }
2644 6744
2645 6745 $file_path = $file->getPathname();
6746 +
6747 + if ( $file->isDir() ) {
6748 + if ( ! $file->isReadable() ) {
6749 + $found['errors'][] = self::unreadable_folder_error( $file_path );
6750 + }
6751 + continue;
6752 + }
6753 +
2646 6754 $extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
2647 6755
6756 + // A .htaccess, .user.ini or php.ini that is not part of the
6757 + // published copy is looked at for what it makes the server do.
6758 + if ( in_array( strtolower( basename( $file_path ) ), array( '.htaccess', '.user.ini', 'php.ini' ), true ) ) {
6759 + $config_path = $this->normalize_rel_path( str_replace( str_replace( '\\', '/', $directory ) . '/', '', str_replace( '\\', '/', $file_path ) ) );
6760 +
6761 + if ( ! isset( $known_normalized[ $config_path ] ) && ! $this->is_path_excluded( $file_path ) ) {
6762 + $config = self::server_config_finding( $file_path, str_replace( ABSPATH, '', $file_path ) );
6763 +
6764 + if ( $config ) {
6765 + $found[ $config['category'] ][] = $config['item'];
6766 + }
6767 + }
6768 + continue;
6769 + }
6770 +
2648 6771 // Only check PHP files
2649 6772 if ( ! in_array( $extension, $php_extensions, true ) ) {
2650 6773 continue;
2651 6774 }
@@ -2673,18 +6796,15 @@
2673 6796 if ( $this->is_silence_golden_file( $file_path ) ) {
2674 6797 continue;
2675 6798 }
2676 6799
2677 - $count++;
6800 + $found['php_files']++;
6801 +
2678 6802 $relative = str_replace( ABSPATH, '', $file_path );
2679 - $pattern = false;
2680 6803
2681 6804 // Check for suspicious content in extra files
2682 6805 // Use strict mode for plugins without checksums to avoid false positives
2683 - if ( $file->getSize() < 512000 ) { // Only scan files < 500KB
2684 - $content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2685 - $pattern = $this->detect_suspicious_pattern( $content, $suspicious_only );
2686 - }
6806 + $pattern = $this->search_file( $file_path, $suspicious_only );
2687 6807
2688 6808 $item = array(
2689 6809 'file' => $relative,
2690 6810 $type => $name,
@@ -2692,21 +6812,49 @@
2692 6812
2693 6813 if ( $pattern ) {
2694 6814 // Suspicious content: promote to suspicious category
2695 6815 $item['type'] = 'suspicious_' . $type;
2696 - /* translators: 1: Plugin or theme name, 2: Suspicious pattern found */
2697 - $item['reason'] = sprintf( __( 'Injected file in %1$s with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
6816 +
6817 + // With an official copy to compare with, the file is known
6818 + // not to belong to it. Without one it may well be the
6819 + // plugin's own, so it is not called injected.
6820 + if ( $suspicious_only ) {
6821 + /* translators: 1: Plugin or theme name, or the folder the file is in, 2: Suspicious pattern found */
6822 + $item['reason'] = sprintf( __( 'File in %1$s with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
6823 + } else {
6824 + /* translators: 1: Plugin or theme name, 2: Suspicious pattern found */
6825 + $item['reason'] = sprintf( __( 'File that is not part of %1$s, with suspicious code: %2$s', 'vigilante' ), $name, $pattern );
6826 + }
6827 +
2698 6828 $found['suspicious'][] = $item;
2699 6829 } elseif ( ! $suspicious_only ) {
2700 6830 // No suspicious patterns and checksums available: report as extra
2701 6831 // Skipped in suspicious_only mode (no checksums) to avoid noise
6832 + if ( $listed >= $max_listed ) {
6833 + if ( ! $truncated ) {
6834 + $truncated = true;
6835 + $found['errors'][] = sprintf(
6836 + /* translators: 1: Plugin or theme name, 2: Number of files */
6837 + __( '%1$s has more extra files than can be listed: only the first %2$d are shown.', 'vigilante' ),
6838 + $name,
6839 + $max_listed
6840 + );
6841 + }
6842 + continue;
6843 + }
6844 +
6845 + $listed++;
2702 6846 $item['type'] = 'extra_' . $type;
2703 6847 $item['reason'] = __( 'PHP file not present in original distribution', 'vigilante' );
2704 6848 $found['extra'][] = $item;
6849 + } elseif ( null === $pattern ) {
6850 + // No official copy to compare with, and the search could
6851 + // not look inside either: said, not passed over.
6852 + $found['extra'][] = $this->not_searched_item( $file_path, $relative );
2705 6853 }
2706 6854 }
2707 6855 } catch ( Exception $e ) {
2708 - // Ignore iterator errors
6856 + $found['errors'][] = self::unreadable_folder_error( $directory );
2709 6857 }
2710 6858
2711 6859 return $found;
2712 6860 }
@@ -2739,9 +6887,14 @@
2739 6887 if ( false === $content ) {
2740 6888 return false;
2741 6889 }
2742 6890
2743 - $normalized = strtolower( trim( str_replace( array( "\r\n", "\r" ), "\n", $content ) ) );
6891 + // Every run of whitespace counts as one space, so the placeholder
6892 + // WordPress itself ships (the opening tag, a line break and the comment)
6893 + // matches the same entries as the one-line form. The comparison is
6894 + // still against the whole file: nothing can follow these few words, and
6895 + // no arrangement of them on several lines is code that runs.
6896 + $normalized = strtolower( trim( preg_replace( '/\s+/', ' ', $content ) ) );
2744 6897
2745 6898 $known = array(
2746 6899 '',
2747 6900 '<?php',
@@ -2754,8 +6907,391 @@
2754 6907 return in_array( $normalized, $known, true );
2755 6908 }
2756 6909
2757 6910 /**
6911 + * Folders of the installation that hold plugins or themes
6912 + *
6913 + * @return array Relative path => true, for the plugins folder and every
6914 + * theme root that lies inside the WordPress directory.
6915 + */
6916 + private function code_roots() {
6917 + global $wp_theme_directories;
6918 +
6919 + $roots = array();
6920 +
6921 + foreach ( array_merge( array( WP_PLUGIN_DIR, get_theme_root() ), is_array( $wp_theme_directories ) ? $wp_theme_directories : array() ) as $root ) {
6922 + $root = untrailingslashit( str_replace( '\\', '/', (string) $root ) );
6923 +
6924 + if ( '' !== $root && 0 === strpos( $root . '/', str_replace( '\\', '/', ABSPATH ) ) ) {
6925 + $roots[ $this->relative_path( $root ) ] = true;
6926 + }
6927 + }
6928 +
6929 + return $roots;
6930 + }
6931 +
6932 + /**
6933 + * What a partial scan offers to exclude, so that the next one reaches the rest
6934 + *
6935 + * The folders of the plugins and themes the scan did check, as lines for
6936 + * Excluded Paths, less the ones that are excluded by now. A scan starts
6937 + * from the beginning every time, so with these out of the way a scan
6938 + * run by hand spends its time on what the last one never got to. They
6939 + * have to come out of the list afterwards, and the tab says so for as
6940 + * long as they are there (see excluded_code_folders()).
6941 + *
6942 + * The list comes from stored results, and whoever reads it is told to
6943 + * paste it into the exclusions, so only what can be the folder of a
6944 + * plugin or of a theme is let through: one line with anything else in it
6945 + * would exclude far more than it says.
6946 + *
6947 + * @param mixed $results Scan results, as run_scan() returns them.
6948 + * @return array Relative paths, one per folder.
6949 + */
6950 + public function folders_to_exclude( $results ) {
6951 + $stored = is_array( $results ) && isset( $results['checked_folders'] ) && is_array( $results['checked_folders'] ) ? $results['checked_folders'] : array();
6952 + $roots = $this->code_roots();
6953 + $lines = array();
6954 +
6955 + foreach ( $stored as $folder ) {
6956 + if ( ! is_string( $folder ) || 1 === preg_match( '/[\\x00-\\x1f\\x7f]/', $folder ) ) {
6957 + continue;
6958 + }
6959 +
6960 + $folder = trim( str_replace( '\\', '/', $folder ), '/' );
6961 + $name = basename( $folder );
6962 +
6963 + if ( '' === $name || '.' === $name || '..' === $name || ! isset( $roots[ dirname( $folder ) ] ) ) {
6964 + continue;
6965 + }
6966 +
6967 + if ( ! is_dir( ABSPATH . $folder ) || $this->is_path_excluded( ABSPATH . $folder . '/.' ) ) {
6968 + continue;
6969 + }
6970 +
6971 + $lines[ $folder ] = true;
6972 + }
6973 +
6974 + return array_keys( $lines );
6975 + }
6976 +
6977 + /**
6978 + * Whole folders of code, or the uploads folder, that Excluded Paths keeps out of every scan
6979 + *
6980 + * A subfolder of uploads or of a plugin is an ordinary exclusion (a cache
6981 + * of compiled templates, say). A whole plugin, a whole theme, the
6982 + * must-use plugins or all of uploads is something else: the scan comes
6983 + * out complete and clean without having looked there. The tab and
6984 + * Security Check go by this list, so that nobody is left thinking those
6985 + * folders are being watched.
6986 + *
6987 + * @return array Relative paths. A folder that holds others (the plugins
6988 + * folder, a theme root) stands for all of them.
6989 + */
6990 + public function excluded_code_folders() {
6991 + if ( empty( $this->options['excluded_paths'] ) || ! is_array( $this->options['excluded_paths'] ) ) {
6992 + return array();
6993 + }
6994 +
6995 + if ( ! function_exists( 'get_plugins' ) ) {
6996 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
6997 + }
6998 +
6999 + $found = array();
7000 + $roots = array_keys( $this->code_roots() );
7001 +
7002 + foreach ( $roots as $root ) {
7003 + if ( $this->is_path_excluded( ABSPATH . $root . '/.' ) ) {
7004 + $found[ $root ] = true;
7005 + }
7006 + }
7007 +
7008 + $plugins_root = $this->relative_path( WP_PLUGIN_DIR );
7009 +
7010 + if ( ! isset( $found[ $plugins_root ] ) ) {
7011 + foreach ( array_keys( get_plugins() ) as $plugin_file ) {
7012 + $slug = dirname( $plugin_file );
7013 +
7014 + if ( '.' !== $slug && $this->is_path_excluded( WP_PLUGIN_DIR . '/' . $slug . '/.' ) ) {
7015 + $found[ $this->relative_path( WP_PLUGIN_DIR . '/' . $slug ) ] = true;
7016 + }
7017 + }
7018 + }
7019 +
7020 + foreach ( wp_get_themes() as $theme ) {
7021 + $dir = $theme->get_stylesheet_directory();
7022 +
7023 + if ( ! isset( $found[ $this->relative_path( dirname( $dir ) ) ] ) && $this->is_path_excluded( $dir . '/.' ) ) {
7024 + $found[ $this->relative_path( $dir ) ] = true;
7025 + }
7026 + }
7027 +
7028 + if ( defined( 'WPMU_PLUGIN_DIR' ) && is_dir( WPMU_PLUGIN_DIR ) && $this->is_path_excluded( WPMU_PLUGIN_DIR . '/.' ) ) {
7029 + $found[ $this->relative_path( WPMU_PLUGIN_DIR ) ] = true;
7030 + }
7031 +
7032 + $uploads = wp_get_upload_dir();
7033 +
7034 + if ( ! empty( $uploads['basedir'] ) && $this->is_path_excluded( $uploads['basedir'] . '/.' ) ) {
7035 + $found[ $this->relative_path( $uploads['basedir'] ) ] = true;
7036 + }
7037 +
7038 + return array_keys( $found );
7039 + }
7040 +
7041 + /**
7042 + * Whether a scan checked less than it was asked to
7043 + *
7044 + * Either it ran out of time, or something could not be read or compared
7045 + * (a folder, a plugin whose copy on WordPress.org could not be fetched).
7046 + * The tab, the scan email and Security Check go by this, so none of them
7047 + * calls clean a result that covers only part of the site.
7048 + *
7049 + * @param mixed $results Scan results, as run_scan() returns them.
7050 + * @return bool
7051 + */
7052 + public static function is_partial( $results ) {
7053 + return is_array( $results ) && ( ! empty( $results['incomplete'] ) || ! empty( $results['errors'] ) );
7054 + }
7055 +
7056 + /**
7057 + * What a scan did not get to, one line per thing
7058 + *
7059 + * The one source of that text, for the tab, the scan email and anything
7060 + * else that shows the result of a scan. In the order of the scan.
7061 + *
7062 + * @param mixed $results Scan results, as run_scan() returns them.
7063 + * @return array Lines ready to show, not escaped. Empty when nothing was
7064 + * left out.
7065 + */
7066 + public static function not_checked_lines( $results ) {
7067 + $left = is_array( $results ) && isset( $results['not_checked'] ) && is_array( $results['not_checked'] ) ? $results['not_checked'] : array();
7068 +
7069 + $areas = isset( $left['areas'] ) && is_array( $left['areas'] ) ? $left['areas'] : array();
7070 + $plugins = isset( $left['plugins'] ) && is_array( $left['plugins'] ) ? array_filter( array_map( 'strval', $left['plugins'] ), 'strlen' ) : array();
7071 + $themes = isset( $left['themes'] ) && is_array( $left['themes'] ) ? array_filter( array_map( 'strval', $left['themes'] ), 'strlen' ) : array();
7072 + $lines = array();
7073 +
7074 + if ( in_array( 'uploads', $areas, true ) ) {
7075 + $lines[] = __( 'The uploads folder was not checked.', 'vigilante' );
7076 + }
7077 +
7078 + if ( in_array( 'core', $areas, true ) ) {
7079 + $lines[] = __( 'The WordPress files were not checked completely.', 'vigilante' );
7080 + }
7081 +
7082 + if ( in_array( 'config', $areas, true ) ) {
7083 + $lines[] = __( 'wp-config.php and .htaccess were not compared.', 'vigilante' );
7084 + }
7085 +
7086 + if ( in_array( 'content', $areas, true ) ) {
7087 + $lines[] = __( 'The other folders of wp-content were not checked.', 'vigilante' );
7088 + }
7089 +
7090 + if ( in_array( 'plugins', $areas, true ) ) {
7091 + $lines[] = __( 'No plugin was checked.', 'vigilante' );
7092 + } elseif ( ! empty( $plugins ) ) {
7093 + /* translators: %s: Comma separated list of plugin names */
7094 + $lines[] = sprintf( __( 'Plugins not checked: %s.', 'vigilante' ), implode( ', ', $plugins ) );
7095 + }
7096 +
7097 + if ( in_array( 'unlisted', $areas, true ) ) {
7098 + $lines[] = __( 'Must-use plugins, drop-ins and loose PHP files were not checked.', 'vigilante' );
7099 + }
7100 +
7101 + if ( in_array( 'themes', $areas, true ) ) {
7102 + $lines[] = __( 'No theme was checked.', 'vigilante' );
7103 + } elseif ( ! empty( $themes ) ) {
7104 + /* translators: %s: Comma separated list of theme names */
7105 + $lines[] = sprintf( __( 'Themes not checked: %s.', 'vigilante' ), implode( ', ', $themes ) );
7106 + }
7107 +
7108 + return $lines;
7109 + }
7110 +
7111 + /**
7112 + * Whether the scheduled scan has not finished for longer than it should
7113 + *
7114 + * A scan that PHP or the server stops halfway stores nothing, so the date
7115 + * of the last one that finished is the only sign left. Twice the interval
7116 + * and one day more, because WP-Cron only runs when the site has a visit.
7117 + *
7118 + * @param int $last_scan Time of the last stored scan, 0 for none.
7119 + * @param string $frequency 'daily' or 'weekly'.
7120 + * @return bool
7121 + */
7122 + public static function is_scan_overdue( $last_scan, $frequency ) {
7123 + $last_scan = (int) $last_scan;
7124 +
7125 + if ( $last_scan <= 0 ) {
7126 + return false;
7127 + }
7128 +
7129 + $every = 'weekly' === $frequency ? WEEK_IN_SECONDS : DAY_IN_SECONDS;
7130 +
7131 + return ( time() - $last_scan ) > ( 2 * $every + DAY_IN_SECONDS );
7132 + }
7133 +
7134 + /**
7135 + * What a kind of finding usually is, and what to do about it
7136 + *
7137 + * The one source of the text shown next to the findings of a scan, so the
7138 + * File Integrity tab and anything else that explains them say the same.
7139 + * Vigilant's own files have their own source,
7140 + * Vigilante_Self_Integrity_Guidance.
7141 + *
7142 + * @param string $type Finding type as stored in the scan results.
7143 + * @return array {
7144 + * @type string $label Name of the kind of finding, for a table cell.
7145 + * @type string $advice What it usually is and what to do. Empty for a
7146 + * type this method does not know.
7147 + * }
7148 + */
7149 + public static function finding_guidance( $type ) {
7150 + switch ( $type ) {
7151 + case 'php_in_uploads':
7152 + return array(
7153 + 'label' => __( 'PHP in uploads', 'vigilante' ),
7154 + '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' ),
7155 + );
7156 +
7157 + case 'double_extension':
7158 + return array(
7159 + 'label' => __( 'Double extension', 'vigilante' ),
7160 + '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' ),
7161 + );
7162 +
7163 + case 'htaccess_in_uploads':
7164 + return array(
7165 + 'label' => __( '.htaccess in uploads', 'vigilante' ),
7166 + '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' ),
7167 + );
7168 +
7169 + case 'php_in_root':
7170 + return array(
7171 + 'label' => __( 'PHP in the site root', 'vigilante' ),
7172 + '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' ),
7173 + );
7174 +
7175 + case 'extra_root':
7176 + return array(
7177 + 'label' => __( 'Extra file in the site root', 'vigilante' ),
7178 + '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' ),
7179 + );
7180 +
7181 + case 'legacy_core':
7182 + return array(
7183 + 'label' => __( 'Old core file', 'vigilante' ),
7184 + 'advice' => __( 'Left behind by an earlier version of WordPress. Nothing uses it any more and it can be deleted.', 'vigilante' ),
7185 + );
7186 +
7187 + case 'suspicious_plugin':
7188 + case 'suspicious_theme':
7189 + return array(
7190 + 'label' => 'suspicious_theme' === $type ? __( 'Hidden code in a theme', 'vigilante' ) : __( 'Hidden code in a plugin', 'vigilante' ),
7191 + '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' ),
7192 + );
7193 +
7194 + case 'extra_plugin':
7195 + case 'extra_theme':
7196 + return array(
7197 + 'label' => 'extra_theme' === $type ? __( 'Extra file in a theme', 'vigilante' ) : __( 'Extra file in a plugin', 'vigilante' ),
7198 + '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' ),
7199 + );
7200 +
7201 + case 'php_in_core':
7202 + return array(
7203 + 'label' => __( 'PHP in a WordPress folder', 'vigilante' ),
7204 + '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' ),
7205 + );
7206 +
7207 + case 'config_in_core':
7208 + return array(
7209 + 'label' => __( 'Configuration file in a WordPress folder', 'vigilante' ),
7210 + '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' ),
7211 + );
7212 +
7213 + case 'php_config_in_uploads':
7214 + return array(
7215 + 'label' => __( 'PHP configuration in uploads', 'vigilante' ),
7216 + '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' ),
7217 + );
7218 +
7219 + case 'script_in_uploads':
7220 + return array(
7221 + 'label' => __( 'Script in uploads', 'vigilante' ),
7222 + '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' ),
7223 + );
7224 +
7225 + case 'php_config_in_root':
7226 + return array(
7227 + 'label' => __( 'PHP configuration in the site root', 'vigilante' ),
7228 + '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' ),
7229 + );
7230 +
7231 + case 'backup_in_root':
7232 + return array(
7233 + 'label' => __( 'Backup in the site root', 'vigilante' ),
7234 + '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' ),
7235 + );
7236 +
7237 + case 'php_in_content':
7238 + return array(
7239 + 'label' => __( 'Loose PHP in wp-content', 'vigilante' ),
7240 + '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' ),
7241 + );
7242 +
7243 + case 'suspicious_content':
7244 + return array(
7245 + 'label' => __( 'Hidden code in wp-content', 'vigilante' ),
7246 + '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' ),
7247 + );
7248 +
7249 + case 'translation_with_code':
7250 + return array(
7251 + 'label' => __( 'Translation file with code', 'vigilante' ),
7252 + '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' ),
7253 + );
7254 +
7255 + case 'not_searched':
7256 + return array(
7257 + 'label' => __( 'File not searched', 'vigilante' ),
7258 + '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' ),
7259 + );
7260 +
7261 + case 'server_config':
7262 + return array(
7263 + 'label' => __( 'Server configuration file', 'vigilante' ),
7264 + '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' ),
7265 + );
7266 +
7267 + case 'core':
7268 + return array(
7269 + 'label' => __( 'WordPress core', 'vigilante' ),
7270 + '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' ),
7271 + );
7272 +
7273 + case 'plugin':
7274 + case 'theme':
7275 + return array(
7276 + 'label' => 'theme' === $type ? __( 'Theme', 'vigilante' ) : __( 'Plugin', 'vigilante' ),
7277 + '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' ),
7278 + );
7279 +
7280 + case 'critical_config':
7281 + return array(
7282 + 'label' => __( 'Configuration file', 'vigilante' ),
7283 + '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' ),
7284 + );
7285 + }
7286 +
7287 + return array(
7288 + 'label' => (string) $type,
7289 + 'advice' => '',
7290 + );
7291 + }
7292 +
7293 + /**
2758 7294 * Check if a path is excluded from scanning
2759 7295 *
2760 7296 * @param string $path File path.
2761 7297 * @return bool
@@ -2905,8 +7441,39 @@
2905 7441 return array_values(
2906 7442 array_filter(
2907 7443 $items,
2908 7444 function ( $item ) {
7445 + /*
7446 + * On a network, wp-config.php and the root .htaccess are not a
7447 + * site's to silence: a change to them is closed by approving it,
7448 + * and approving takes a network administrator since 2.11.3. The
7449 + * ignore list is an option of each site, so until 2.11.8 the
7450 + * administrator of the main site without network rights hid a
7451 + * pending change from the network administrator's own screen by
7452 + * posting the file name to the ignore handler.
7453 + */
7454 + if ( is_multisite() && is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
7455 + return true;
7456 + }
7457 +
7458 + // Findings about the manifest and the version of Vigilant
7459 + // itself are not about one file, so no entry of the list may
7460 + // hide them, on a single site either: ignoring the row of
7461 + // MANIFEST.sha256 took a replaced manifest out of the email.
7462 + // Vigilante_Self_Integrity::filter_ignored_findings() keeps
7463 + // them the same way.
7464 + 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 ) ) {
7465 + return true;
7466 + }
7467 + // Nor the findings of the walk of Vigilant's folder (a folder
7468 + // that cannot be listed, the folder that could not be walked):
7469 + // their path ends in a slash, and ignoring that row left the
7470 + // scan with no row and no email while the self-protection
7471 + // status stayed critical.
7472 + if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) && '/' === substr( (string) ( $item['file'] ?? '' ), -1 ) ) {
7473 + return true;
7474 + }
7475 +
2909 7476 $file = is_array( $item ) && isset( $item['file'] ) ? $item['file'] : '';
2910 7477 return ! in_array( $file, $this->ignored_files, true );
2911 7478 }
2912 7479 )
@@ -2927,32 +7494,25 @@
2927 7494 */
2928 7495 private function maybe_send_notification( $results ) {
2929 7496 $options = is_array( $this->options ) ? $this->options : array();
2930 7497
2931 - // Count critical_config separately from regular modified so we can treat it
2932 - // as "serious" for notification level purposes (same tier as suspicious/extra).
2933 - $has_critical_config = false;
2934 - foreach ( $results['modified'] ?? array() as $item ) {
2935 - if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) {
2936 - $has_critical_config = true;
2937 - break;
2938 - }
2939 - }
2940 -
2941 - // Collect closed/removed plugins (excluding ignored slugs). These count
2942 - // as "serious" for notification purposes: a closed plugin in wp.org is
2943 - // a security-critical finding, same tier as a suspicious file.
7498 + // Closed/removed plugins (excluding ignored slugs) travel in this
7499 + // email, in the same tier as a suspicious file.
2944 7500 $closed_plugins = $this->collect_closed_plugins_for_email();
2945 - $has_closed = ! empty( $closed_plugins );
2946 7501
2947 - $has_suspicious = ! empty( $results['suspicious'] ) || ! empty( $results['extra'] ) || $has_critical_config || $has_closed;
2948 - $has_modified = ! empty( $results['modified'] );
7502 + /*
7503 + * Self-protection is not part of this decision. Its alert is its own
7504 + * and no setting switches it off, so this email is about the files of
7505 + * the site: core, plugins, themes, uploads and the two shared
7506 + * configuration files. Missing files do not send it on their own: it
7507 + * has no section to list them in.
7508 + */
2949 7509
2950 - // Instant alert: send for suspicious, extra, critical_config, modified
2951 - // files, or closed plugins.
2952 - $instant_alert = ! empty( $options['instant_alert'] );
2953 - if ( $instant_alert && ( $has_suspicious || $has_modified ) ) {
2954 - $this->send_notification( $results, 'all', $closed_plugins );
7510 + // Instant alert: every finding travels, modified files included,
7511 + // whatever the level below says. A scan with nothing to report goes
7512 + // through as well, so that what was reported is forgotten.
7513 + if ( ! empty( $options['instant_alert'] ) ) {
7514 + $this->send_new_findings( $results, 'all', $closed_plugins );
2955 7515 return;
2956 7516 }
2957 7517
2958 7518 // Determine notify level with backward compatibility
@@ -2967,22 +7527,142 @@
2967 7527 }
2968 7528 }
2969 7529
2970 7530 if ( 'disabled' === $notify_level ) {
7531 + // Nothing is sent, and nothing stays on record as reported:
7532 + // whoever switches the email back on is told about everything
7533 + // that is pending then.
7534 + delete_option( self::NOTIFIED_OPTION );
2971 7535 return;
2972 7536 }
2973 7537
2974 7538 // 'suspicious_only' treats suspicious/extra, critical_config AND closed
2975 - // plugins as serious.
2976 - if ( 'suspicious_only' === $notify_level && ! $has_suspicious ) {
7539 + // plugins as serious. A scan with nothing to send at this level still
7540 + // goes through send_new_findings(), which then forgets what it had
7541 + // reported, so a finding that goes away and comes back is new again.
7542 + $this->send_new_findings( $results, $notify_level, $closed_plugins );
7543 + }
7544 +
7545 + /**
7546 + * What the scan email has already reported to the recipients
7547 + */
7548 + const NOTIFIED_OPTION = 'vigilante_integrity_notified';
7549 +
7550 + /**
7551 + * Send the scan email only when the scan found something new
7552 + *
7553 + * Until now the email went out after every scan for as long as a finding
7554 + * was still listed, so a plugin that rewrote its block in .htaccess meant
7555 + * the same alert every day until somebody approved the change. Now every
7556 + * finding is reported once: the email goes out when a scan finds something
7557 + * the recipients have not been told about, and it lists everything that is
7558 + * pending, old and new.
7559 + *
7560 + * A finding is the file, its kind and its content (by its hash), so a
7561 + * file that changes again under the same name is new, also when the
7562 + * change keeps its size and its date. What goes away is forgotten, so it
7563 + * is new again if it comes back. Nothing is recorded when the email could
7564 + * not be sent, and the next scan tries again.
7565 + *
7566 + * @param array $results Scan results.
7567 + * @param string $notify_level 'all' or 'suspicious_only'.
7568 + * @param array $closed_plugins Closed plugins, keyed by slug.
7569 + */
7570 + private function send_new_findings( $results, $notify_level, $closed_plugins ) {
7571 + $keys = $this->notification_keys( $results, $closed_plugins, 'all' === $notify_level );
7572 + $told = get_option( self::NOTIFIED_OPTION, array() );
7573 + $told = is_array( $told ) ? $told : array();
7574 +
7575 + // A partial scan cannot tell whether what it did not reach is still
7576 + // there, so it forgets nothing: with the findings of an unchecked
7577 + // plugin forgotten, the next scan that reached it would report them
7578 + // again as new, and on a site whose scans are always partial that
7579 + // is the same email over and over.
7580 + $known = self::is_partial( $results ) ? array_values( array_unique( array_merge( $keys, $told ) ) ) : $keys;
7581 +
7582 + // The record is bounded, and the comparison is not: what does not fit
7583 + // in the record is reported again, never left out. The keys come in
7584 + // order of importance, so what does not fit is the least of it.
7585 + $record = array_slice( $known, 0, 5000 );
7586 +
7587 + if ( empty( array_diff( $keys, $told ) ) ) {
7588 + if ( count( $record ) !== count( $told ) ) {
7589 + update_option( self::NOTIFIED_OPTION, $record, false );
7590 + }
2977 7591 return;
2978 7592 }
2979 7593
2980 - if ( ! $has_suspicious && ! $has_modified ) {
2981 - return;
7594 + if ( $this->send_notification( $results, $notify_level, $closed_plugins ) ) {
7595 + update_option( self::NOTIFIED_OPTION, $record, false );
2982 7596 }
7597 + }
2983 7598
2984 - $this->send_notification( $results, $notify_level, $closed_plugins );
7599 + /**
7600 + * One key per finding the scan email would carry at this level
7601 + *
7602 + * @param array $results Scan results.
7603 + * @param array $closed_plugins Closed plugins, keyed by slug.
7604 + * @param bool $with_modified Whether files that are only modified travel
7605 + * in the email at this level.
7606 + * @return array
7607 + */
7608 + private function notification_keys( $results, $closed_plugins, $with_modified ) {
7609 + $keys = array(
7610 + 'config' => array(),
7611 + 'closed' => array(),
7612 + 'suspicious' => array(),
7613 + 'modified' => array(),
7614 + 'extra' => array(),
7615 + );
7616 +
7617 + foreach ( (array) ( $results['modified'] ?? array() ) as $item ) {
7618 + if ( ! is_array( $item ) ) {
7619 + continue;
7620 + }
7621 +
7622 + $type = (string) ( $item['type'] ?? '' );
7623 + $config = 'critical_config' === $type;
7624 +
7625 + // Vigilant's own files have their own alert.
7626 + if ( 'vigilante_self' === $type || ( ! $config && ! $with_modified ) ) {
7627 + continue;
7628 + }
7629 +
7630 + $keys[ $config ? 'config' : 'modified' ][] = md5( 'modified|' . $type . '|' . (string) ( $item['file'] ?? '' ) . '|' . (string) ( $item['actual_hash'] ?? '' ) );
7631 + }
7632 +
7633 + foreach ( (array) $closed_plugins as $slug => $info ) {
7634 + $keys['closed'][] = md5( 'closed|' . $slug . '|' . ( is_array( $info ) ? (string) ( $info['state'] ?? '' ) : '' ) );
7635 + }
7636 +
7637 + foreach ( array( 'suspicious', 'extra' ) as $bucket ) {
7638 + foreach ( (array) ( $results[ $bucket ] ?? array() ) as $item ) {
7639 + $type = is_array( $item ) ? (string) ( $item['type'] ?? '' ) : '';
7640 +
7641 + if ( 'vigilante_self' === $type ) {
7642 + continue;
7643 + }
7644 +
7645 + $file = is_array( $item ) ? (string) ( $item['file'] ?? '' ) : (string) $item;
7646 + $state = '';
7647 + $path = ABSPATH . ltrim( $file, '/' );
7648 +
7649 + // By content, so a file rewritten with the same size and the
7650 + // same date is still a different file. The ones too large to
7651 + // hash on every scan go by size, date and the time of their
7652 + // last change of any kind, which writing to a file moves and
7653 + // which cannot be set back the way the date can.
7654 + if ( '' !== $file && is_file( $path ) ) {
7655 + $state = filesize( $path ) <= MB_IN_BYTES ? (string) md5_file( $path ) : filesize( $path ) . ':' . filemtime( $path ) . ':' . filectime( $path );
7656 + }
7657 +
7658 + $keys[ $bucket ][] = md5( $bucket . '|' . $type . '|' . $file . '|' . $state );
7659 + }
7660 + }
7661 +
7662 + // In order of importance: only the first 5,000 are remembered, and
7663 + // what is not remembered is reported again.
7664 + return array_values( array_unique( array_merge( $keys['config'], $keys['closed'], $keys['suspicious'], $keys['modified'], $keys['extra'] ) ) );
2985 7665 }
2986 7666
2987 7667 /**
2988 7668 * Collect the closed/removed plugins (excluding ignored slugs) so they can
@@ -3017,8 +7697,10 @@
3017 7697 'suspicious' => array_merge( $results1['suspicious'] ?? array(), $results2['suspicious'] ?? array() ),
3018 7698 'extra' => array_merge( $results1['extra'] ?? array(), $results2['extra'] ?? array() ),
3019 7699 'new' => $results1['new'] ?? array(),
3020 7700 'errors' => array_merge( $results1['errors'] ?? array(), $results2['errors'] ?? array() ),
7701 + // Plugins and themes with no official copy to compare with.
7702 + 'unverified' => array_merge( $results1['unverified'] ?? array(), $results2['unverified'] ?? array() ),
3021 7703 'scan_time' => $results1['scan_time'] ?? 0,
3022 7704 'incomplete' => $results1['incomplete'] ?? false,
3023 7705 );
3024 7706 }
@@ -3064,8 +7746,37 @@
3064 7746 $regular_modified[] = $item;
3065 7747 }
3066 7748 }
3067 7749
7750 + /*
7751 + * Self-protection does not travel in this email any more: it has its own
7752 + * alert, which no setting switches off (Vigilante_Self_Integrity::
7753 + * maybe_send_self_alert()). Older stored results can still carry its
7754 + * rows, so they are dropped here instead of being listed as ordinary
7755 + * files.
7756 + */
7757 + foreach ( array( 'suspicious', 'extra', 'missing' ) as $self_bucket ) {
7758 + if ( empty( $results[ $self_bucket ] ) || ! is_array( $results[ $self_bucket ] ) ) {
7759 + continue;
7760 + }
7761 + $results[ $self_bucket ] = array_values(
7762 + array_filter(
7763 + $results[ $self_bucket ],
7764 + function ( $item ) {
7765 + return ! ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) );
7766 + }
7767 + )
7768 + );
7769 + }
7770 + $regular_modified = array_values(
7771 + array_filter(
7772 + $regular_modified,
7773 + function ( $item ) {
7774 + return ! ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) );
7775 + }
7776 + )
7777 + );
7778 +
3068 7779 $suspicious_count = count( $results['suspicious'] ?? array() );
3069 7780 $extra_count = count( $results['extra'] ?? array() );
3070 7781 $critical_config_count = count( $critical_config );
3071 7782 $modified_count = count( $regular_modified );
@@ -3070,16 +7781,31 @@
3070 7781 $critical_config_count = count( $critical_config );
3071 7782 $modified_count = count( $regular_modified );
3072 7783 $closed_count = count( $closed_plugins );
3073 7784
3074 - // Use more urgent subject when suspicious files, critical config changes
3075 - // or closed plugins are found (all three are security-critical).
3076 - if ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 ) {
7785 + // The subject says what was found. Suspicious files and closed plugins
7786 + // keep the alert wording. A change in wp-config.php or .htaccess with
7787 + // nothing else next to it names the files instead: most of the time it
7788 + // is a plugin rewriting its own block, and "SECURITY ALERT" on every
7789 + // one of those taught people to read the subject as "I was hacked".
7790 + if ( $suspicious_count > 0 || $closed_count > 0 ) {
3077 7791 $subject = sprintf(
3078 7792 /* translators: %s: Site name */
3079 7793 __( '[%s] SECURITY ALERT: File integrity issues detected', 'vigilante' ),
3080 7794 $site_name
3081 7795 );
7796 + } elseif ( $critical_config_count > 0 ) {
7797 + $changed = array();
7798 + foreach ( $critical_config as $item ) {
7799 + $changed[] = (string) ( $item['file'] ?? '' );
7800 + }
7801 +
7802 + $subject = sprintf(
7803 + /* translators: 1: Site name, 2: The file that changed, or both of them, such as "wp-config.php, .htaccess" */
7804 + __( '[%1$s] %2$s changed: review needed', 'vigilante' ),
7805 + $site_name,
7806 + implode( ', ', array_unique( array_filter( $changed ) ) )
7807 + );
3082 7808 } else {
3083 7809 $subject = sprintf(
3084 7810 /* translators: %s: Site name */
3085 7811 __( '[%s] File integrity issues detected', 'vigilante' ),
@@ -3114,9 +7840,9 @@
3114 7840 // Suspicious files section
3115 7841 if ( ! empty( $results['suspicious'] ) ) {
3116 7842 $inner .= $this->get_section_html(
3117 7843 __( 'Suspicious files', 'vigilante' ),
3118 - __( 'These files may contain malicious code. Review immediately.', 'vigilante' ),
7844 + __( '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' ),
3119 7845 $results['suspicious'],
3120 7846 '#d63638',
3121 7847 '#fef1f1',
3122 7848 20,
@@ -3127,9 +7853,9 @@
3127 7853 // Extra files section
3128 7854 if ( ! empty( $results['extra'] ) ) {
3129 7855 $inner .= $this->get_section_html(
3130 7856 __( 'Extra files', 'vigilante' ),
3131 - __( 'PHP files not in the original WordPress.org distribution.', 'vigilante' ),
7857 + __( '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' ),
3132 7858 $results['extra'],
3133 7859 '#b32d2e',
3134 7860 '#fdf6f4',
3135 7861 20,
@@ -3140,9 +7866,9 @@
3140 7866 // Critical config files section (wp-config.php, .htaccess modified outside Vigilante)
3141 7867 if ( ! empty( $critical_config ) ) {
3142 7868 $inner .= $this->get_section_html(
3143 7869 __( 'Critical config files modified', 'vigilante' ),
3144 - __( 'These files are common targets for code injection. Review the changes and approve if they are legitimate.', 'vigilante' ),
7870 + __( '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' ),
3145 7871 $critical_config,
3146 7872 '#e36210',
3147 7873 '#fdf2e6',
3148 7874 10,
@@ -3153,9 +7879,9 @@
3153 7879 // Modified files section (only if notify_level is 'all')
3154 7880 if ( 'all' === $notify_level && ! empty( $regular_modified ) ) {
3155 7881 $inner .= $this->get_section_html(
3156 7882 __( 'Modified files', 'vigilante' ),
3157 - __( 'Checksum mismatch with WordPress.org originals.', 'vigilante' ),
7883 + __( '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' ),
3158 7884 $regular_modified,
3159 7885 '#dba617',
3160 7886 '#fdf8e8',
3161 7887 15,
@@ -3170,8 +7896,31 @@
3170 7896 if ( $closed_count > 0 ) {
3171 7897 $inner .= $this->build_closed_plugins_email_section( $closed_plugins );
3172 7898 }
3173 7899
7900 + // A partial scan says so and names what it did not get to. With no
7901 + // finding in a plugin that was never looked at, silence here read as
7902 + // "that plugin is fine".
7903 + if ( self::is_partial( $results ) ) {
7904 + $partial_lines = array_merge(
7905 + array_slice( array_map( 'strval', (array) ( $results['errors'] ?? array() ) ), 0, 10 ),
7906 + self::not_checked_lines( $results )
7907 + );
7908 +
7909 + $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>';
7910 + $inner .= '<ul style="margin:0 0 16px 18px;padding:0;font-size:13px;color:#50575e;">';
7911 +
7912 + foreach ( $partial_lines as $partial_line ) {
7913 + $inner .= '<li>' . esc_html( $partial_line ) . '</li>';
7914 + }
7915 +
7916 + $inner .= '</ul>';
7917 + }
7918 +
7919 + // Where the explanation is. The email stays a list; what each kind of
7920 + // finding usually is and what to do about it lives next to the results.
7921 + $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>';
7922 +
3174 7923 // CTA button
3175 7924 $inner .= Vigilante_Email_Template::button(
3176 7925 admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-last-scan' ),
3177 7926 __( 'Review in Vigilant', 'vigilante' )
@@ -3177,13 +7926,20 @@
3177 7926 __( 'Review in Vigilant', 'vigilante' )
3178 7927 );
3179 7928
3180 7929 $is_alert = ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 );
3181 - $title = $is_alert
3182 - ? __( 'Security alert', 'vigilante' )
3183 - : __( 'File integrity report', 'vigilante' );
3184 7930
3185 - Vigilante_Email_Template::send( $to, $subject, $title, $inner, $is_alert );
7931 + // Same three cases as the subject.
7932 + if ( $suspicious_count > 0 || $closed_count > 0 ) {
7933 + $title = __( 'Security alert', 'vigilante' );
7934 + } elseif ( $critical_config_count > 0 ) {
7935 + $title = __( 'Configuration file changed', 'vigilante' );
7936 + } else {
7937 + $title = __( 'File integrity report', 'vigilante' );
7938 + }
7939 +
7940 + // The caller records what was reported only when the email went out.
7941 + return (bool) Vigilante_Email_Template::send( $to, $subject, $title, $inner, $is_alert );
3186 7942 }
3187 7943
3188 7944 /**
3189 7945 * Build the closed + removed plugins block for the scan email.
@@ -3308,16 +8064,32 @@
3308 8064
3309 8065 /**
3310 8066 * Add a file to the ignored list
3311 8067 *
8068 + * The path is stored exactly as it comes: the callers are the AJAX
8069 + * handlers, which have already read it and checked it against the shared
8070 + * files and Vigilant's own, and the scan compares the stored strings with
8071 + * the paths it finds one by one (in_array(), strict). This used to run
8072 + * sanitize_text_field() over it, which deletes every %XX octet and collapses
8073 + * repeated spaces, so the entry never matched the file it was made for
8074 + * (measured on 1 oct 2026 against 3.0.1). Anything that is not a plain
8075 + * string, is empty or carries tags is not a path and is refused. That guards
8076 + * a direct caller: the AJAX handlers read the path with wp_strip_all_tags()
8077 + * first, which shortens a name with a stray < instead of handing it over
8078 + * intact (see ajax_ignore_file()).
8079 + *
3312 8080 * @param string $file_path Relative file path to ignore.
3313 8081 * @return bool
3314 8082 */
3315 8083 public function ignore_file( $file_path ) {
8084 + if ( ! is_string( $file_path ) || '' === $file_path || wp_strip_all_tags( $file_path ) !== $file_path ) {
8085 + return false;
8086 + }
8087 +
3316 8088 $ignored = get_option( 'vigilante_ignored_files', array() );
3317 8089
3318 8090 if ( ! in_array( $file_path, $ignored, true ) ) {
3319 - $ignored[] = sanitize_text_field( $file_path );
8091 + $ignored[] = $file_path;
3320 8092 return update_option( 'vigilante_ignored_files', $ignored );
3321 8093 }
3322 8094
3323 8095 return true;