PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | includes/class-file-integrity.php +1905 -33 2.10.42.11.8 View file →
@@ -82,8 +82,1351 @@
82 82 */
83 83 const BASELINE_OPTION = 'vigilante_critical_files_baseline';
84 84
85 85 /**
86 + * Option that records which version last redacted the stored baseline.
87 + *
88 + * @since 2.11.2
89 + */
90 + const BASELINE_REDACTION_OPTION = 'vigilante_baseline_redaction';
91 +
92 + /**
93 + * Network option recording that the one-off sweep of per-site baselines ran.
94 + *
95 + * @since 2.11.3
96 + */
97 + const BASELINE_SWEEP_OPTION = 'vigilante_baseline_sweep';
98 +
99 + /**
100 + * The migration the sweep marker stands for.
101 + *
102 + * A literal, not VIGILANTE_VERSION, and the difference is the whole point.
103 + * 2.11.3 stored the running version, so every release after it rearmed the
104 + * sweep: the first dashboard load on the main site walked the network with
105 + * switch_to_blog() to find nothing, because there was nothing left to find,
106 + * for ever, at the price of the one walk the marker exists to avoid.
107 + *
108 + * A plain boolean would fix that too, and would also leave no way to fire a
109 + * second network sweep the day another migration needs one. A literal costs
110 + * nothing today and keeps that door open. The redaction marker keeps the
111 + * running version on purpose: there the point IS to run again when the list
112 + * of what has to be redacted grows, and the cost of reopening it is one
113 + * option read rather than a walk of the network.
114 + *
115 + * And it must be a value NO version ever wrote into this option. The first
116 + * draft used '2.11.3', which is precisely what 2.11.3 wrote there, as its
117 + * own VIGILANTE_VERSION and BEFORE starting the walk: out in the wild that
118 + * value means "started, maybe unfinished". Reading it as "finished" left
119 + * every network whose 2.11.3 walk was cut short unswept for good, subsites
120 + * still holding the database password and the eight keys. Reproduced on the
121 + * Multisite install by a third cross review. The price of the new value is
122 + * that networks that did finish in 2.11.3 walk once more and find nothing.
123 + *
124 + * @since 2.11.4
125 + */
126 + const BASELINE_SWEEP_MIGRATION = 'network-sweep-done';
127 +
128 + /**
129 + * Network option with the fingerprint of every block Vigilant itself wrote
130 + * into wp-config.php or the root .htaccess.
131 + *
132 + * The integrity scan leaves Vigilant's own blocks out of the hash, so that
133 + * rewriting them is not reported as somebody else's change. Until 2.11.5 it
134 + * left out whatever sat between the markers, without looking. From 2.11.5 a
135 + * block is left out only if its content is exactly what Vigilant wrote, as
136 + * recorded here at write time.
137 + *
138 + * @since 2.11.5
139 + */
140 + const OWNED_BLOCKS_OPTION = 'vigilante_owned_blocks';
141 +
142 + /**
143 + * Network option marking that the blocks already on disk have been claimed.
144 + *
145 + * @since 2.11.5
146 + */
147 + const OWNED_BLOCKS_CLAIM_OPTION = 'vigilante_owned_blocks_claim';
148 +
149 + /**
150 + * Value stored when the claim is done. Deliberately not a version number:
151 + * that is the lesson of BASELINE_SWEEP_MIGRATION above.
152 + *
153 + * @since 2.11.5
154 + */
155 + const OWNED_BLOCKS_CLAIMED = 'claimed';
156 +
157 + /**
158 + * What replaces a secret value kept in the baseline.
159 + *
160 + * Fixed forever: if this string ever changes, every stored baseline
161 + * suddenly differs from the freshly redacted file and every site reports a
162 + * change to wp-config.php that never happened.
163 + *
164 + * @since 2.11.2
165 + */
166 + const REDACTED_MARKER = '[redacted by Vigilant]';
167 +
168 + /**
169 + * Constant names whose value is checked even where the file does not name them
170 + *
171 + * The eight WordPress keys and salts and the database credentials. Until
172 + * 2.11.7 this list, plus names that read like a credential, was what got
173 + * redacted, and a real wp-config.php collects secrets under any name:
174 + * FTP_PASS, SMTP passwords, cloud keys inside serialize( array( ... ) ),
175 + * any const. Since 2.11.8 every value is redacted and this list only feeds
176 + * the output check of baseline_content().
177 + *
178 + * @since 2.11.2
179 + *
180 + * @var string[]
181 + */
182 + private static $secret_constants = array(
183 + 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST',
184 + 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
185 + 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
186 + );
187 +
188 + /**
189 + * Core constants whose value stays readable in the baseline copy
190 + *
191 + * Where the site lives, where its folders are, how much memory it gets:
192 + * none of it is a secret and all of it is what a diff of wp-config.php is
193 + * read for. Every other value is redacted. A list of what is secret can
194 + * never be complete, which is how 2.11.2 to 2.11.7 missed FTP_PASS; a list
195 + * of what is not can be short and still be right.
196 + *
197 + * @since 2.11.8
198 + *
199 + * @var string[]
200 + */
201 + private static $readable_constants = array(
202 + 'ABSPATH', 'WPINC', 'WP_HOME', 'WP_SITEURL', 'WP_CONTENT_DIR', 'WP_CONTENT_URL',
203 + 'WP_PLUGIN_DIR', 'WP_PLUGIN_URL', 'WPMU_PLUGIN_DIR', 'WPMU_PLUGIN_URL', 'UPLOADS',
204 + 'WP_LANG_DIR', 'WP_TEMP_DIR', 'WP_DEBUG_LOG', 'WP_MEMORY_LIMIT', 'WP_MAX_MEMORY_LIMIT',
205 + 'WP_ENVIRONMENT_TYPE', 'WP_DEVELOPMENT_MODE', 'WP_AUTO_UPDATE_CORE', 'FS_METHOD',
206 + 'DB_CHARSET', 'DB_COLLATE', 'DOMAIN_CURRENT_SITE', 'PATH_CURRENT_SITE', 'NOBLOGREDIRECT',
207 + 'COOKIE_DOMAIN', 'COOKIEPATH', 'SITECOOKIEPATH', 'ADMIN_COOKIE_PATH', 'PLUGINS_COOKIE_PATH',
208 + 'WP_DEFAULT_THEME', 'WPLANG',
209 + );
210 +
211 + /**
212 + * Read the critical files baseline, from where it belongs
213 + *
214 + * Both watched files, wp-config.php and the root .htaccess, belong to the
215 + * whole network: there is one of each per installation, not one per site.
216 + * Keeping the baseline in a per-site option meant every site of a network
217 + * stored its own copy of the same wp-config.php, so a network of fifty
218 + * sites held fifty copies of the same credentials, and a cleanup that ran
219 + * on one site left the other forty nine untouched. Reported by @calzbert on
220 + * 10 sep 2026 and reproduced on the Multisite install. Since 2.11.3 there
221 + * is one baseline per network.
222 + *
223 + * No is_multisite() branch on purpose, and this is worth reading before
224 + * anyone adds one back: the core functions already make the distinction.
225 + * Verified in the installed core, wp-includes/option.php, where
226 + * get_network_option() falls back to get_option() on a single site and
227 + * update_network_option() falls back to update_option( $option, $value,
228 + * false ), autoload already off, which is exactly what this needs. Two
229 + * branches doing the same thing are two branches that can drift apart,
230 + * and one of them did during this very change.
231 + *
232 + * @since 2.11.3
233 + *
234 + * @return array
235 + */
236 + private function read_baseline() {
237 + $baseline = get_site_option( self::BASELINE_OPTION, array() );
238 +
239 + return is_array( $baseline ) ? $baseline : array();
240 + }
241 +
242 + /**
243 + * Store the critical files baseline where read_baseline() looks for it
244 + *
245 + * @since 2.11.3
246 + *
247 + * @param array $baseline Baseline to store.
248 + * @return bool
249 + */
250 + private function write_baseline( $baseline ) {
251 + return update_site_option( self::BASELINE_OPTION, $baseline );
252 + }
253 +
254 + /**
255 + * Fingerprint of a block exactly as the integrity scan reads it back
256 + *
257 + * @since 2.11.5
258 + *
259 + * @param string $block Block from start marker to end marker, inclusive.
260 + * @return string
261 + */
262 + private static function block_fingerprint( $block ) {
263 + return md5( str_replace( array( "\r\n", "\r" ), "\n", (string) $block ) );
264 + }
265 +
266 + /**
267 + * Record a block Vigilant has just written
268 + *
269 + * Called by the writers right after a verified write, so the scan can tell
270 + * Vigilant's block from anything else carrying the same markers. By default
271 + * it replaces the earlier record for that marker: after a write, only the
272 + * block just written is Vigilant's.
273 + *
274 + * @since 2.11.5
275 + *
276 + * @param string $filename 'wp-config.php' or '.htaccess'.
277 + * @param string $marker_start Start marker of the block.
278 + * @param string $block Block from start marker to end marker, inclusive.
279 + * @param bool $replace Drop earlier records for the same marker first.
280 + * @return bool
281 + */
282 + public static function remember_owned_block( $filename, $marker_start, $block, $replace = true ) {
283 + $owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() );
284 + $owned = is_array( $owned ) ? $owned : array();
285 + $file = ( isset( $owned[ $filename ] ) && is_array( $owned[ $filename ] ) ) ? $owned[ $filename ] : array();
286 +
287 + if ( $replace ) {
288 + foreach ( $file as $fingerprint => $marker ) {
289 + if ( $marker === $marker_start ) {
290 + unset( $file[ $fingerprint ] );
291 + }
292 + }
293 + }
294 +
295 + $file[ self::block_fingerprint( $block ) ] = $marker_start;
296 + $owned[ $filename ] = $file;
297 +
298 + return update_site_option( self::OWNED_BLOCKS_OPTION, $owned );
299 + }
300 +
301 + /**
302 + * Forget the blocks recorded for a marker, once Vigilant has removed them
303 + *
304 + * @since 2.11.5
305 + *
306 + * @param string $filename 'wp-config.php' or '.htaccess'.
307 + * @param string $marker_start Start marker of the block.
308 + * @return bool
309 + */
310 + public static function forget_owned_blocks( $filename, $marker_start ) {
311 + $owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() );
312 +
313 + if ( ! is_array( $owned ) || empty( $owned[ $filename ] ) || ! is_array( $owned[ $filename ] ) ) {
314 + return true;
315 + }
316 +
317 + $changed = false;
318 +
319 + foreach ( $owned[ $filename ] as $fingerprint => $marker ) {
320 + if ( $marker === $marker_start ) {
321 + unset( $owned[ $filename ][ $fingerprint ] );
322 + $changed = true;
323 + }
324 + }
325 +
326 + return $changed ? update_site_option( self::OWNED_BLOCKS_OPTION, $owned ) : true;
327 + }
328 +
329 + /**
330 + * Whether a block is one Vigilant wrote
331 + *
332 + * @since 2.11.5
333 + *
334 + * @param string $filename 'wp-config.php' or '.htaccess'.
335 + * @param string $block Block from start marker to end marker, inclusive.
336 + * @return bool
337 + */
338 + private static function is_owned_block( $filename, $block ) {
339 + $owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() );
340 +
341 + return is_array( $owned )
342 + && isset( $owned[ $filename ] )
343 + && is_array( $owned[ $filename ] )
344 + && isset( $owned[ $filename ][ self::block_fingerprint( $block ) ] );
345 + }
346 +
347 + /**
348 + * Whether the blocks already on disk have been claimed
349 + *
350 + * @since 2.11.5
351 + *
352 + * @return bool
353 + */
354 + private function owned_blocks_claimed() {
355 + return self::OWNED_BLOCKS_CLAIMED === get_site_option( self::OWNED_BLOCKS_CLAIM_OPTION );
356 + }
357 +
358 + /**
359 + * The baseline copy of a critical file, with no secret in it
360 + *
361 + * The integrity scan keeps a copy of wp-config.php so it can show which
362 + * lines changed. Until 2.11.1 that copy was the file itself minus the
363 + * plugin's own blocks, so the options table held the database password and
364 + * the eight authentication keys and salts, and anybody who later read the
365 + * database or a backup of it got them without ever touching the
366 + * filesystem. Reported by the automated security review of wp.org on 9 sep
367 + * 2026 and fixed in 2.11.2.
368 + *
369 + * The hash is still taken over the whole file, so a change to a secret is
370 + * still detected; what changes is that the diff cannot show it, which is
371 + * the right trade.
372 + *
373 + * @since 2.11.2
374 + *
375 + * @param string $filename Critical file name.
376 + * @param string $normalized Normalized content.
377 + * @return string Content safe to store, or '' when it cannot be made safe.
378 + */
379 + private function baseline_content( $filename, $normalized ) {
380 + if ( '.htaccess' === $filename ) {
381 + return $this->redact_server_secrets( $normalized );
382 + }
383 +
384 + if ( 'wp-config.php' !== $filename ) {
385 + return $normalized;
386 + }
387 +
388 + $redacted = $this->redact_secrets( $normalized );
389 +
390 + /*
391 + * Belt and braces, and this is the part that matters: the redaction
392 + * above is the thing most likely to miss a shape nobody thought of,
393 + * and the cost of missing one is a secret in the database. So the
394 + * result is checked against the values actually in force, and if any
395 + * of them survived, nothing is stored at all. The scan then reports the
396 + * change without a line diff, which the interface already handles,
397 + * instead of leaking.
398 + *
399 + * Until 2.11.7 the check covered the twelve constants of WordPress and
400 + * nothing else, so a value the regular expression missed went straight
401 + * through it. It now covers every constant the file names and every
402 + * environment variable it reads.
403 + *
404 + * It runs against the copy that is actually stored. A value in force
405 + * that sits inside the value of a readable constant, such as a Redis
406 + * prefix equal to the domain inside WP_HOME, is not a secret left
407 + * behind, so that one alone is not looked for; without that, every such
408 + * site would lose its diff. The first version of this checked a
409 + * stricter copy instead, and a secret inside a kept include path went
410 + * straight past it (cross review of 2.11.8).
411 + *
412 + * Only values of eight characters or more are checked: DB_NAME is
413 + * often something like "local" or "wp", and looking for that inside a
414 + * PHP file matches by accident every time.
415 + */
416 + if ( '' === $redacted ) {
417 + return '';
418 + }
419 +
420 + $shown = $this->readable_values_in_force();
421 +
422 + foreach ( $this->values_in_force( $normalized ) as $value ) {
423 + foreach ( $shown as $readable ) {
424 + if ( false !== strpos( $readable, $value ) ) {
425 + continue 2;
426 + }
427 + }
428 +
429 + if ( false !== strpos( $redacted, $value ) ) {
430 + return '';
431 + }
432 + }
433 +
434 + return $redacted;
435 + }
436 +
437 + /**
438 + * Replace every value in wp-config.php with a marker
439 + *
440 + * Reads the file as PHP tokens and replaces every string in it: quoted,
441 + * with variables inside, heredoc and nowdoc. What stays is what names a
442 + * thing rather than holding it: the name passed to define(), defined(),
443 + * constant() and getenv(), the name in putenv( 'NAME=value' ), array keys,
444 + * an index such as $_ENV['NAME'], and strings of a single character that
445 + * are not the value of a define(). Also the value of the constants in
446 + * $readable_constants, the table prefix and a path passed to require or
447 + * include, which are not secrets and are what a diff of this file is read
448 + * for. A path is kept only while it looks like one, and only up to where
449 + * its expression ends.
450 + *
451 + * Until 2.11.7 this was a regular expression over define() with a list of
452 + * names, and it missed FTP_PASS, SMTP passwords, cloud keys inside
453 + * serialize( array( ... ) ), every const and every value read with a
454 + * fallback. Measured while preparing 2.11.8: 10 of 15 real shapes stored
455 + * their secret.
456 + *
457 + * The marker always goes in single quotes, whatever the original used, so
458 + * a copy redacted by an earlier version and the same file redacted today
459 + * read the same line for line.
460 + *
461 + * @since 2.11.2
462 + * @since 2.11.8 Reads tokens and redacts every value.
463 + *
464 + * @param string $content Normalized wp-config.php content.
465 + * @return string Redacted content, or '' when it cannot be read as tokens.
466 + */
467 + private function redact_secrets( $content ) {
468 + if ( ! function_exists( 'token_get_all' ) ) {
469 + return '';
470 + }
471 +
472 + $marker = "'" . self::REDACTED_MARKER . "'";
473 + $tokens = self::merged_tokens( token_get_all( (string) $content ) );
474 + $count = count( $tokens );
475 + $names = defined( 'T_NAME_FULLY_QUALIFIED' ) ? array( T_STRING, T_NAME_FULLY_QUALIFIED ) : array( T_STRING );
476 + $includes = array( T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE );
477 + $out = '';
478 + $depth = 0;
479 + $keep_until = -1;
480 + $define_at = -1;
481 + $in_include = false;
482 + $include_depth = 0;
483 + $include_ends = array( T_CLOSE_TAG, T_BOOLEAN_OR, T_BOOLEAN_AND, T_LOGICAL_OR, T_LOGICAL_AND, T_COALESCE );
484 +
485 + for ( $i = 0; $i < $count; $i++ ) {
486 + list( $type, $text, $plain ) = $tokens[ $i ];
487 +
488 + if ( '(' === $type ) {
489 + $depth++;
490 + } elseif ( ')' === $type ) {
491 + // The closing parenthesis of a readable define(), or of any define().
492 + if ( $depth === $keep_until ) {
493 + $keep_until = -1;
494 + }
495 + if ( $depth === $define_at ) {
496 + $define_at = -1;
497 + }
498 + $depth--;
499 +
500 + // A parenthesis that closes around the include ends its path.
501 + if ( $in_include && $depth < $include_depth ) {
502 + $in_include = false;
503 + }
504 + } elseif ( in_array( $type, $includes, true ) ) {
505 + $in_include = true;
506 + $include_depth = $depth;
507 + } elseif ( $in_include && ( in_array( $type, array( ';', '{', '}', '?', ':', ',' ), true ) || in_array( $type, $include_ends, true ) ) ) {
508 + /*
509 + * The path of an include ends where its expression does. The
510 + * first version of this only ended it at ';', so the value in
511 + * `( include 'db.php' ) || define( 'FTP_PASS', '...' )`, in a
512 + * ternary after require, or after a closing tag, was kept.
513 + * Found by the cross review of 2.11.8.
514 + */
515 + $in_include = false;
516 + }
517 +
518 + if ( T_COMMENT === $type || T_DOC_COMMENT === $type ) {
519 + $out .= $this->redact_comment( $text );
520 + continue;
521 + }
522 +
523 + if ( T_INLINE_HTML === $type ) {
524 + $out .= ( '' === trim( $text ) ) ? $text : $marker;
525 + continue;
526 + }
527 +
528 + if ( 'string' !== $type ) {
529 + $out .= $text;
530 + continue;
531 + }
532 +
533 + $prev = self::significant_token( $tokens, $i, -1 );
534 + $next = self::significant_token( $tokens, $i, 1 );
535 + $ptype = ( null === $prev ) ? null : $tokens[ $prev ][0];
536 + $ntype = ( null === $next ) ? null : $tokens[ $next ][0];
537 + $call = ( '(' === $ptype ) ? self::significant_token( $tokens, $prev, -1 ) : null;
538 + $inner = $plain ? substr( $text, 1, -1 ) : null;
539 +
540 + if ( $plain && null !== $call && in_array( $tokens[ $call ][0], $names, true ) ) {
541 + $function = strtolower( ltrim( $tokens[ $call ][1], '\\' ) );
542 +
543 + if ( in_array( $function, array( 'define', 'defined', 'constant', 'getenv' ), true ) ) {
544 + if ( 'define' === $function ) {
545 + $define_at = $depth;
546 +
547 + if ( in_array( $inner, self::$readable_constants, true ) ) {
548 + $keep_until = $depth;
549 + }
550 + }
551 + $out .= $text;
552 + continue;
553 + }
554 +
555 + if ( 'putenv' === $function && false !== strpos( $inner, '=' ) ) {
556 + $out .= "'" . substr( $inner, 0, strpos( $inner, '=' ) + 1 ) . self::REDACTED_MARKER . "'";
557 + continue;
558 + }
559 + }
560 +
561 + // The token before an opening bracket or an assignment, when there is one.
562 + $before = ( '[' === $ptype || '=' === $ptype ) ? self::significant_token( $tokens, $prev, -1 ) : null;
563 + $btoken = ( null === $before ) ? array( null, null ) : $tokens[ $before ];
564 +
565 + /*
566 + * The value of a define() is redacted whatever its length, as it was
567 + * up to 2.11.7, so an empty password reads the same in a copy stored
568 + * then as in today's; the first version of this kept strings of one
569 + * character there and a file awaiting review showed credential lines
570 + * nobody had touched (cross review of 2.11.8).
571 + */
572 + $is_define_value = ( -1 !== $define_at && $depth === $define_at && ',' === $ptype );
573 + $is_path = $in_include && $plain
574 + && preg_match( '#^[A-Za-z0-9_./\-]+$#', (string) $inner )
575 + && ( false !== strpos( (string) $inner, '/' ) || '.php' === substr( (string) $inner, -4 ) );
576 +
577 + $keep = ( $plain && strlen( $inner ) <= 1 && ! $is_define_value )
578 + || T_DOUBLE_ARROW === $ntype
579 + || ( '[' === $ptype && ']' === $ntype && in_array( $btoken[0], array( T_VARIABLE, T_STRING, ']', ')', '}' ), true ) )
580 + || $keep_until >= 0
581 + || $is_path
582 + || ( '=' === $ptype && ';' === $ntype && T_VARIABLE === $btoken[0] && '$table_prefix' === $btoken[1] );
583 +
584 + $out .= $keep ? $text : $marker;
585 + }
586 +
587 + return $out;
588 + }
589 +
590 + /**
591 + * PHP tokens with every string folded into a single token
592 + *
593 + * The tokenizer splits a string with variables inside, a heredoc and a
594 + * backtick command into several tokens. For the redaction each of them is
595 + * one value, so they come back as a single token of type 'string'. The
596 + * third field says whether it is a plain quoted literal.
597 + *
598 + * @since 2.11.8
599 + *
600 + * @param array $raw Output of token_get_all().
601 + * @return array List of array( type, text, plain ).
602 + */
603 + private static function merged_tokens( $raw ) {
604 + $tokens = array();
605 + $count = count( $raw );
606 +
607 + for ( $i = 0; $i < $count; $i++ ) {
608 + $token = $raw[ $i ];
609 +
610 + if ( '"' === $token || '`' === $token ) {
611 + $text = $token;
612 + for ( $i++; $i < $count; $i++ ) {
613 + $text .= is_array( $raw[ $i ] ) ? $raw[ $i ][1] : $raw[ $i ];
614 + if ( $raw[ $i ] === $token ) {
615 + break;
616 + }
617 + }
618 + $tokens[] = array( 'string', $text, false );
619 + continue;
620 + }
621 +
622 + if ( is_array( $token ) && T_START_HEREDOC === $token[0] ) {
623 + $text = $token[1];
624 + for ( $i++; $i < $count; $i++ ) {
625 + $text .= is_array( $raw[ $i ] ) ? $raw[ $i ][1] : $raw[ $i ];
626 + if ( is_array( $raw[ $i ] ) && T_END_HEREDOC === $raw[ $i ][0] ) {
627 + break;
628 + }
629 + }
630 + $tokens[] = array( 'string', $text, false );
631 + continue;
632 + }
633 +
634 + // An unterminated string comes back as T_ENCAPSED_AND_WHITESPACE
635 + // on its own, and it is a value like any other.
636 + if ( is_array( $token ) && ( T_CONSTANT_ENCAPSED_STRING === $token[0] || T_ENCAPSED_AND_WHITESPACE === $token[0] ) ) {
637 + $tokens[] = array( 'string', $token[1], T_CONSTANT_ENCAPSED_STRING === $token[0] );
638 + continue;
639 + }
640 +
641 + $tokens[] = is_array( $token ) ? array( $token[0], $token[1], false ) : array( $token, $token, false );
642 + }
643 +
644 + return $tokens;
645 + }
646 +
647 + /**
648 + * Index of the nearest token that is not whitespace or a comment
649 + *
650 + * @since 2.11.8
651 + *
652 + * @param array $tokens Output of merged_tokens().
653 + * @param int $from Index to start from, not included.
654 + * @param int $step -1 to look back, 1 to look ahead.
655 + * @return int|null
656 + */
657 + private static function significant_token( $tokens, $from, $step ) {
658 + $count = count( $tokens );
659 +
660 + for ( $i = $from + $step; $i >= 0 && $i < $count; $i += $step ) {
661 + if ( ! in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
662 + return $i;
663 + }
664 + }
665 +
666 + return null;
667 + }
668 +
669 + /**
670 + * Redact a comment, keeping its plain words
671 + *
672 + * To the tokenizer a comment is text, and wp-config.php files keep old
673 + * credentials in them, commented out or in a note. The first version of
674 + * this, in the same release, redacted what was between quotes: an
675 + * apostrophe in prose ("Don't use 'the-old-password'") paired with the
676 + * opening quote of the secret and left it out, and a secret without quotes
677 + * was never touched. Found by the cross review of 2.11.8.
678 + *
679 + * So it works the other way round. A comment keeps its plain words
680 + * (lowercase, capitalised or uppercase letters, or two capitalised parts
681 + * such as WordPress, and docblock tags), constant names, and anything
682 + * shorter than eight characters; every other run of characters, a URL, a
683 + * key, a password with a digit in it, becomes the marker. The value of a
684 + * commented-out define() goes in single quotes whatever its length, as in
685 + * code and as 2.11.2 to 2.11.7 wrote it, so a copy stored by those
686 + * versions reads the same line for line. What this cannot tell from prose
687 + * is a password made only of plain letters; the output check still
688 + * catches it when it is a value in force.
689 + *
690 + * @since 2.11.8
691 + *
692 + * @param string $comment Comment token text.
693 + * @return string
694 + */
695 + private function redact_comment( $comment ) {
696 + $marker = self::REDACTED_MARKER;
697 + $readable = self::$readable_constants;
698 +
699 + // Only the text between the delimiters is redacted: "/**#@-*/" in
700 + // wp-config-sample.php is a single run of eight characters, and
701 + // replacing it whole took the comment markers with it.
702 + if ( ! preg_match( '#\A(/\*\*?|//|\#)(.*?)(\*/)?\z#s', $comment, $parts ) ) {
703 + $parts = array( $comment, '', $comment );
704 + }
705 +
706 + $open = $parts[1];
707 + $close = isset( $parts[3] ) ? $parts[3] : '';
708 + $comment = preg_replace_callback(
709 + '/(\bdefine\s*\(\s*([\'"])((?:\\\\.|(?!\2).)*)\2\s*,\s*)([\'"])((?:\\\\.|(?!\4).)*)\4/i',
710 + function ( $match ) use ( $marker, $readable ) {
711 + return in_array( $match[3], $readable, true ) ? $match[0] : $match[1] . "'" . $marker . "'";
712 + },
713 + $parts[2]
714 + );
715 +
716 + if ( null === $comment ) {
717 + return '';
718 + }
719 +
720 + $redacted = preg_replace_callback(
721 + '/[^\s\'"`(),;\[\]{}<>=]+/u',
722 + function ( $match ) use ( $marker ) {
723 + $word = $match[0];
724 + $core = rtrim( $word, '.:!?' );
725 +
726 + // Plain words only, without hyphens: a passphrase written as
727 + // lowercase words joined by hyphens reads as prose otherwise, and
728 + // the PoC of this very fix caught one surviving.
729 + if ( strlen( $core ) < 8
730 + || preg_match( '/^@?(?:\p{Lu}?\p{Ll}+|\p{Lu}+)$/u', $core )
731 + || preg_match( '/^\p{Lu}\p{Ll}+\p{Lu}\p{Ll}+$/u', $core )
732 + || preg_match( '/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/', $core )
733 + ) {
734 + return $word;
735 + }
736 +
737 + return $marker . substr( $word, strlen( $core ) );
738 + },
739 + $comment
740 + );
741 +
742 + // A failed replacement, on invalid UTF-8 for one, drops the comment
743 + // rather than keep it whole.
744 + return ( null === $redacted ) ? '' : $open . $redacted . $close;
745 + }
746 +
747 + /**
748 + * Values in force of what a wp-config.php names
749 + *
750 + * Every user constant whose name appears in the file, the twelve of
751 + * WordPress wherever they were defined, and the environment variables the
752 + * file reads or sets. Arrays are walked to their leaves, since define()
753 + * takes arrays. Only strings: numbers are never redacted and are not
754 + * secrets, and the first version of this counted them and wiped the diff
755 + * of any file with a large number in force (cross review of 2.11.8). The
756 + * readable constants are left out, and so is anything shorter than eight
757 + * characters.
758 + *
759 + * @since 2.11.8
760 + *
761 + * @param string $content Normalized wp-config.php content.
762 + * @return string[]
763 + */
764 + private function values_in_force( $content ) {
765 + $defined = get_defined_constants( true );
766 + $user = isset( $defined['user'] ) ? $defined['user'] : array();
767 + $names = self::$secret_constants;
768 + $values = array();
769 +
770 + if ( preg_match_all( '/[A-Za-z_][A-Za-z0-9_]*/', (string) $content, $words ) ) {
771 + $names = array_merge( $names, $words[0] );
772 + }
773 +
774 + foreach ( array_unique( $names ) as $name ) {
775 + if ( array_key_exists( $name, $user ) && ! in_array( $name, self::$readable_constants, true ) ) {
776 + $values = array_merge( $values, self::string_leaves( $user[ $name ] ) );
777 + }
778 + }
779 +
780 + if ( preg_match_all( '/\b(?:getenv|putenv)\s*\(\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)|\$_ENV\s*\[\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)/', (string) $content, $env ) ) {
781 + foreach ( array_filter( array_merge( $env[1], $env[2] ) ) as $name ) {
782 + $value = getenv( $name );
783 +
784 + if ( is_string( $value ) ) {
785 + $values[] = $value;
786 + }
787 + }
788 + }
789 +
790 + $long = array();
791 +
792 + foreach ( $values as $value ) {
793 + if ( strlen( $value ) >= 8 ) {
794 + $long[ $value ] = $value;
795 + }
796 + }
797 +
798 + return array_values( $long );
799 + }
800 +
801 + /**
802 + * Every string inside a constant value
803 + *
804 + * @since 2.11.8
805 + *
806 + * @param mixed $value Constant value.
807 + * @return string[]
808 + */
809 + private static function string_leaves( $value ) {
810 + if ( is_array( $value ) ) {
811 + $leaves = array();
812 +
813 + foreach ( $value as $item ) {
814 + $leaves = array_merge( $leaves, self::string_leaves( $item ) );
815 + }
816 +
817 + return $leaves;
818 + }
819 +
820 + return is_string( $value ) ? array( $value ) : array();
821 + }
822 +
823 + /**
824 + * Values in force of the readable constants, the ones kept in the copy
825 + *
826 + * @since 2.11.8
827 + *
828 + * @return string[]
829 + */
830 + private function readable_values_in_force() {
831 + $values = array();
832 +
833 + foreach ( self::$readable_constants as $name ) {
834 + if ( defined( $name ) ) {
835 + $values = array_merge( $values, self::string_leaves( constant( $name ) ) );
836 + }
837 + }
838 +
839 + return $values;
840 + }
841 +
842 + /**
843 + * Replace the values a root .htaccess can carry as credentials
844 + *
845 + * The .htaccess is not a secrets file, but it can hold a few: an
846 + * environment variable handed to PHP with SetEnv, an Authorization header
847 + * set for a backend, or a php_value with a password, a key, a licence or a
848 + * session store address with its auth in it. The directive and its name
849 + * stay, the value goes. Line based, which is how Apache reads it too. Since
850 + * the cross review of 2.11.8 also any request or response header whose name
851 + * reads like a credential (X-Api-Key, a cookie, a signature) and a
852 + * RewriteCond that compares against key=, token= or the like, the way a
853 + * staging site is opened with a secret in the query string. What it cannot
854 + * see is a credential written in any other shape.
855 + *
856 + * @since 2.11.8
857 + *
858 + * @param string $content Normalized .htaccess content.
859 + * @return string
860 + */
861 + private function redact_server_secrets( $content ) {
862 + $redacted = preg_replace(
863 + array(
864 + '/^([ \t]*SetEnv[ \t]+\S+[ \t]+)\S.*$/mi',
865 + '/^([ \t]*(?:RequestHeader|Header)[ \t]+(?:always[ \t]+)?\S+[ \t]+[\w-]*(?:auth|key|token|secret|pass|cookie|sig)[\w-]*[ \t]+)\S.*$/mi',
866 + '/^([ \t]*php_(?:admin_)?value[ \t]+\S*(?:pass|pw|secret|key|token|licen|auth|save_path)\S*[ \t]+)\S.*$/mi',
867 + '/^([ \t]*RewriteCond[ \t]+\S+[ \t]+)\S*(?:key|token|secret|pass|auth|sig)[\w-]*=\S*/mi',
868 + ),
869 + '${1}' . self::REDACTED_MARKER,
870 + (string) $content
871 + );
872 +
873 + return ( null === $redacted ) ? '' : $redacted;
874 + }
875 +
876 + /**
877 + * Promote a per-site baseline to the network record before dropping it
878 + *
879 + * Up to 2.11.2 the baseline was a per-site option, so on a network every
880 + * site kept its own copy of the same two files. Those copies go, but what a
881 + * copy records is which version of the file the owner approved, and that
882 + * has to survive: rebuilding the baseline from disk would take whatever is
883 + * there right now as approved, so a wp-config.php modified and still
884 + * awaiting review would be blessed in silence.
885 + *
886 + * WHICH copy becomes the network record is not a detail, and 2.11.3 got it
887 + * wrong. This runs from the scan, under wp-cron, on whichever site gets
888 + * traffic first, and the sweep from the main site can be hours away because
889 + * it waits for a network administrator to open a dashboard. So on a network
890 + * with traffic spread around, the record of the whole installation was
891 + * whatever the first subsite to scan happened to hold.
892 + *
893 + * That is harmless while every copy agrees, which is the ordinary case. The
894 + * reason they can disagree is the very thing 2.11.3 fixed: until then,
895 + * approving a change to wp-config.php took manage_options, which on a
896 + * network the administrator of every subsite holds. If a change was
897 + * approved on some subsite while the main site still had it pending review,
898 + * promoting that subsite's copy retires a warning nobody decided to retire.
899 + *
900 + * Hence the order, file by file: what the network record already holds
901 + * wins, then the main site, then the site this runs on. Between the copies,
902 + * the main site beats a subsite, which is @calzbert's point, reported after
903 + * reading the 2.11.3 diff.
904 + *
905 + * What this does NOT protect, said plainly because an earlier wording
906 + * claimed more: if the network record already holds a file, that entry
907 + * wins, even when it was written from disk by the .htaccess writer on
908 + * init:20 while a third-party edit was pending review. What survives is a
909 + * file the network record does not hold yet, which is the wp-config.php
910 + * case that 2.11.3 lost. The .htaccess case is pre-existing and needs the
911 + * writers to pass their before-hash, see update_critical_file_baseline().
912 + *
913 + * @since 2.11.4
914 + *
915 + * @param array|null $per_site Baseline stored for the site this runs on.
916 + * @return bool True when the network record covers everything the per-site
917 + * copy had, which is the only case where dropping it is safe.
918 + */
919 + private function promote_per_site_baseline( $per_site ) {
920 + $network = get_site_option( self::BASELINE_OPTION, array() );
921 +
922 + if ( ! is_array( $network ) ) {
923 + $network = array();
924 + }
925 +
926 + /*
927 + * Three sources, filled in one from another, file by file. It used to
928 + * be all or nothing: if the network record existed at all, this
929 + * returned at once and the caller dropped the per-site copy anyway.
930 + *
931 + * That looked safe and was not, because the network record can be born
932 + * holding ONE of the two files. maybe_sync_server_files() runs on init
933 + * and rewrites the root .htaccess by itself, and the writer calls
934 + * update_critical_file_baseline( '.htaccess' ), which creates the
935 + * network option with that single entry. init runs before admin_init,
936 + * so on a network on Apache this is the ordinary order of an update,
937 + * not a race: the cleanup then found the option "already there", kept
938 + * nothing, and deleted the per-site copies that held the approved
939 + * record of wp-config.php. The next scan met a file it had never seen
940 + * and stored whatever was on disk as approved, which is the silent
941 + * blessing this whole function exists to prevent. Reproduced on the
942 + * Multisite install on 10 sep 2026, found by a cross review.
943 + *
944 + * Order of authority: what the network already says wins, then the main
945 + * site, then the site this runs on. Nothing is ever overwritten and
946 + * nothing is dropped for being late.
947 + */
948 + $sources = array( $network );
949 +
950 + if ( ! is_main_site() ) {
951 + $from_main = get_blog_option( get_main_site_id(), self::BASELINE_OPTION, null );
952 +
953 + if ( is_array( $from_main ) ) {
954 + $sources[] = $from_main;
955 + }
956 + }
957 +
958 + if ( is_array( $per_site ) ) {
959 + $sources[] = $per_site;
960 + }
961 +
962 + $merged = array();
963 +
964 + foreach ( $sources as $source ) {
965 + foreach ( $source as $filename => $data ) {
966 + if ( isset( $merged[ $filename ] ) || ! is_array( $data ) || ! isset( $data['hash'] ) ) {
967 + continue;
968 + }
969 +
970 + // Only the content carries secrets; the hash and the size,
971 + // which are what say "this is the version that was approved",
972 + // go over untouched.
973 + if ( isset( $data['content'] ) && is_string( $data['content'] ) ) {
974 + $data['content'] = $this->baseline_content( $filename, $data['content'] );
975 + }
976 +
977 + $merged[ $filename ] = $data;
978 + }
979 + }
980 +
981 + if ( array_diff_key( $merged, $network ) ) {
982 + $this->write_baseline( $merged );
983 + }
984 +
985 + if ( ! is_array( $per_site ) ) {
986 + return true;
987 + }
988 +
989 + /*
990 + * Is every file this copy had a record of now on the network record?
991 + * Only then may the caller drop it. And the question is asked of what
992 + * is STORED, not of $merged, which is only what this request MEANT to
993 + * store. Asking $merged makes the answer true by construction, because
994 + * the copy is one of the sources above, so the guard could never fire
995 + * and redact_in_place() in the caller was unreachable code.
996 + *
997 + * The write does not always land, and the case that matters is not a
998 + * broken database, it is the same race as the bug this function fixes.
999 + * On a network updating from 2.11.2 the network option does not exist
1000 + * yet, so update_network_option() takes the $old_value === false branch
1001 + * and delegates to add_network_option() (wp-includes/option.php:2434).
1002 + * If another request created the option in between, that call either
1003 + * returns false without writing (option.php:2201) or, when this process
1004 + * still holds "does not exist" in its own notoptions cache, skips the
1005 + * check and INSERTs a second row: wp_sitemeta has no unique index on
1006 + * meta_key, so the record ends up duplicated and get_network_option()
1007 + * hands back whichever row comes first. Reproduced on the Multisite
1008 + * install on 10 sep 2026, with the .htaccess writer of init:20 racing a
1009 + * promotion: two rows, the approved hash of wp-config.php out of reach,
1010 + * and the per-site copy deleted all the same. Found by a cross review.
1011 + *
1012 + * Both cache keys go before rereading, and that is not belt and braces.
1013 + * add_network_option() caches the value it believes it wrote
1014 + * (option.php:2221), so a plain read hands back the very array that did
1015 + * not survive; and a stale notoptions would answer "no such option"
1016 + * without touching the database, which reads as "nothing is covered".
1017 + * Measured: without dropping the cache this guard still returns true.
1018 + */
1019 + $network_id = get_current_network_id();
1020 + wp_cache_delete( $network_id . ':' . self::BASELINE_OPTION, 'site-options' );
1021 + wp_cache_delete( $network_id . ':notoptions', 'site-options' );
1022 +
1023 + $stored = get_site_option( self::BASELINE_OPTION, array() );
1024 +
1025 + if ( ! is_array( $stored ) ) {
1026 + return false;
1027 + }
1028 +
1029 + foreach ( $per_site as $filename => $data ) {
1030 + if ( is_array( $data ) && isset( $data['hash'] ) && ! isset( $stored[ $filename ] ) ) {
1031 + return false;
1032 + }
1033 + }
1034 +
1035 + return true;
1036 + }
1037 +
1038 + /**
1039 + * Strip the secrets from a per-site copy that cannot be dropped yet
1040 + *
1041 + * The copy stays because it holds the only record of an approved file, but
1042 + * what it must not keep for one more minute is the database password and
1043 + * the eight keys and salts. The two things are separable and this is where
1044 + * they get separated.
1045 + *
1046 + * @since 2.11.4
1047 + *
1048 + * @param array $per_site Baseline stored for the current site.
1049 + * @return void
1050 + */
1051 + private function redact_in_place( $per_site ) {
1052 + $changed = false;
1053 +
1054 + foreach ( $per_site as $filename => $data ) {
1055 + if ( ! is_array( $data ) || ! isset( $data['content'] ) || ! is_string( $data['content'] ) ) {
1056 + continue;
1057 + }
1058 +
1059 + $safe = $this->baseline_content( $filename, $data['content'] );
1060 +
1061 + if ( $safe !== $data['content'] ) {
1062 + $per_site[ $filename ]['content'] = $safe;
1063 + $changed = true;
1064 + }
1065 + }
1066 +
1067 + if ( $changed ) {
1068 + update_option( self::BASELINE_OPTION, $per_site );
1069 + }
1070 + }
1071 +
1072 + /**
1073 + * Network option recording the version whose results cleanup walked the network
1074 + *
1075 + * @since 2.11.8
1076 + */
1077 + const RESULTS_SWEEP_OPTION = 'vigilante_results_sweep';
1078 +
1079 + /**
1080 + * Clean the stored scan results of every site of the network, once per version
1081 + *
1082 + * redact_stored_results() runs per site from admin_init and from the scan,
1083 + * so a subsite with the module off whose dashboard nobody opens kept the
1084 + * lines of wp-config.php its last scan stored, with whatever that version
1085 + * failed to redact. Same gap 2.11.3 and 2.11.4 closed for the baseline copy;
1086 + * found for the results by the cross review of 2.11.8. It runs from the
1087 + * network sweep, for a network administrator on the main site, and has its
1088 + * own marker because the baseline sweep is already done on every network
1089 + * that updated through 2.11.4.
1090 + *
1091 + * @since 2.11.8
1092 + */
1093 + private function maybe_sweep_network_results() {
1094 + if ( VIGILANTE_VERSION === get_site_option( self::RESULTS_SWEEP_OPTION ) ) {
1095 + return;
1096 + }
1097 +
1098 + update_site_option( self::RESULTS_SWEEP_OPTION, VIGILANTE_VERSION );
1099 +
1100 + $site_ids = get_sites(
1101 + array(
1102 + 'fields' => 'ids',
1103 + 'number' => 0,
1104 + 'network_id' => get_current_network_id(),
1105 + 'update_site_meta_cache' => false,
1106 + )
1107 + );
1108 +
1109 + foreach ( $site_ids as $site_id ) {
1110 + switch_to_blog( $site_id );
1111 + $this->redact_stored_results();
1112 + restore_current_blog();
1113 + }
1114 + }
1115 +
1116 + /**
1117 + * The diff of a shared file as a site that does not own it gets it
1118 + *
1119 + * No lines, and a flag the screens read to say where the lines are.
1120 + *
1121 + * @since 2.11.8
1122 + *
1123 + * @return array
1124 + */
1125 + public static function network_only_diff() {
1126 + return array(
1127 + 'added' => array(),
1128 + 'removed' => array(),
1129 + 'unavailable' => true,
1130 + 'network' => true,
1131 + );
1132 + }
1133 +
1134 + /**
1135 + * Take out of the last stored scan what the baseline copy no longer keeps
1136 + *
1137 + * The results of the last scan are an option of each site, and the diff of
1138 + * a critical file travels inside them line by line, redacted the way the
1139 + * version that ran the scan redacted. Until 2.11.7 that let FTP_PASS and
1140 + * friends through, and on a network every subsite with the module on kept
1141 + * its own copy of the lines. This runs once per version with the rest of
1142 + * the cleanup:
1143 + *
1144 + * - Where the shared files do not belong to this site, no line is kept.
1145 + * - Lines of wp-config.php are dropped. A single line cannot be read as
1146 + * PHP reliably (half a heredoc is just words), and the next scan rebuilds
1147 + * them from the whole file.
1148 + * - Lines of .htaccess are directives, one per line, and are redacted in
1149 + * place.
1150 + *
1151 + * @since 2.11.8
1152 + */
1153 + private function redact_stored_results() {
1154 + $results = get_option( 'vigilante_last_integrity_results' );
1155 +
1156 + if ( ! is_array( $results ) || empty( $results['modified'] ) || ! is_array( $results['modified'] ) ) {
1157 + return;
1158 + }
1159 +
1160 + $owns = Vigilante_Settings::owns_shared_files();
1161 + $changed = false;
1162 +
1163 + foreach ( $results['modified'] as $index => $item ) {
1164 + if ( ! is_array( $item ) || 'critical_config' !== ( $item['type'] ?? '' ) || ! isset( $item['diff'] ) || ! is_array( $item['diff'] ) ) {
1165 + continue;
1166 + }
1167 +
1168 + if ( ! $owns ) {
1169 + if ( empty( $item['diff']['network'] ) ) {
1170 + $results['modified'][ $index ]['diff'] = self::network_only_diff();
1171 + $changed = true;
1172 + }
1173 + continue;
1174 + }
1175 +
1176 + if ( 'wp-config.php' === ( $item['file'] ?? '' ) ) {
1177 + if ( ! empty( $item['diff']['added'] ) || ! empty( $item['diff']['removed'] ) ) {
1178 + $results['modified'][ $index ]['diff'] = array(
1179 + 'added' => array(),
1180 + 'removed' => array(),
1181 + 'unavailable' => true,
1182 + 'rescan' => true,
1183 + );
1184 + $changed = true;
1185 + }
1186 + continue;
1187 + }
1188 +
1189 + foreach ( array( 'added', 'removed' ) as $side ) {
1190 + if ( empty( $item['diff'][ $side ] ) || ! is_array( $item['diff'][ $side ] ) ) {
1191 + continue;
1192 + }
1193 +
1194 + foreach ( $item['diff'][ $side ] as $line_index => $line ) {
1195 + if ( ! is_array( $line ) || ! isset( $line['content'] ) || ! is_string( $line['content'] ) ) {
1196 + continue;
1197 + }
1198 +
1199 + $safe = $this->redact_server_secrets( $line['content'] );
1200 +
1201 + if ( $safe !== $line['content'] ) {
1202 + $results['modified'][ $index ]['diff'][ $side ][ $line_index ]['content'] = $safe;
1203 + $changed = true;
1204 + }
1205 + }
1206 + }
1207 + }
1208 +
1209 + if ( $changed ) {
1210 + update_option( 'vigilante_last_integrity_results', $results );
1211 + }
1212 + }
1213 +
1214 + /**
1215 + * Clean up what earlier versions stored, wherever they stored it
1216 + *
1217 + * Two jobs, and the second one only exists on a network.
1218 + *
1219 + * The first: versions up to 2.11.1 kept the contents of wp-config.php in
1220 + * the baseline, credentials included, so what is already on disk is
1221 + * redacted in place.
1222 + *
1223 + * The second: up to 2.11.2 that baseline was a per-site option, so on a
1224 + * network every site had its own copy of the same file. This runs per site
1225 + * and removes that copy, because the baseline now lives in a single
1226 + * network option. Doing it here is what makes the cleanup reach a site
1227 + * whose dashboard nobody ever opens: this method is called from the scan
1228 + * as well as from admin_init, and the scan runs on every site through
1229 + * wp-cron with front-end traffic alone.
1230 + *
1231 + * The gate option stays per site on purpose. It records that THIS site has
1232 + * been cleaned, which is exactly the per-site fact being tracked.
1233 + *
1234 + * @since 2.11.2
1235 + */
1236 + public function maybe_redact_stored_baseline() {
1237 + if ( VIGILANTE_VERSION === get_option( self::BASELINE_REDACTION_OPTION ) ) {
1238 + return;
1239 + }
1240 +
1241 + /*
1242 + * The per-site copy left behind by 2.11.2 and earlier. On a network it
1243 + * holds the database password and the eight keys and salts, so it goes,
1244 + * but never before what it records has been carried over:
1245 + * promote_per_site_baseline() explains why the record has to outlive
1246 + * the copy, and which copy wins when they disagree. Measured on the
1247 + * Multisite install while writing 2.11.3: without that, the first scan
1248 + * after the migration reported zero modified files where it had to
1249 + * report one.
1250 + */
1251 + $pending = false;
1252 +
1253 + if ( is_multisite() ) {
1254 + $per_site = get_option( self::BASELINE_OPTION, null );
1255 +
1256 + if ( null !== $per_site ) {
1257 + if ( $this->promote_per_site_baseline( $per_site ) ) {
1258 + delete_option( self::BASELINE_OPTION );
1259 + } elseif ( is_array( $per_site ) ) {
1260 + // Something this copy recorded is not on the network record
1261 + // yet, so it does not go: it is the only evidence of what
1262 + // was approved. The secrets do go, right now, because that
1263 + // part cannot wait for the next pass.
1264 + $this->redact_in_place( $per_site );
1265 + $pending = true;
1266 + }
1267 + }
1268 + }
1269 +
1270 + $baseline = $this->read_baseline();
1271 +
1272 + if ( is_array( $baseline ) ) {
1273 + $changed = false;
1274 +
1275 + foreach ( $baseline as $filename => $data ) {
1276 + if ( ! is_array( $data ) || ! isset( $data['content'] ) || ! is_string( $data['content'] ) ) {
1277 + continue;
1278 + }
1279 +
1280 + $safe = $this->baseline_content( $filename, $data['content'] );
1281 +
1282 + if ( $safe !== $data['content'] ) {
1283 + $baseline[ $filename ]['content'] = $safe;
1284 + $changed = true;
1285 + }
1286 + }
1287 +
1288 + if ( $changed ) {
1289 + $this->write_baseline( $baseline );
1290 + }
1291 + }
1292 +
1293 + $this->redact_stored_results();
1294 +
1295 + /*
1296 + * The gate does not close while a per-site copy is still waiting to be
1297 + * promoted. Closing it would end the retries for a whole version: the
1298 + * copy would sit there unread, the file it records would be missing
1299 + * from the network record, and the next scan would take whatever is on
1300 + * disk as approved. Not closing it is not free, though, and the first
1301 + * wording here said "one option read": measured cold on the Multisite
1302 + * install, it is 6 SQL queries per admin request against 0 with the gate
1303 + * closed, admin-ajax.php and the heartbeat included, two of them from the
1304 + * cache invalidation in promote_per_site_baseline(). Acceptable only
1305 + * because it converges: the stuck case this guards against resolves on
1306 + * the next pass that gets its write through.
1307 + */
1308 + if ( ! $pending ) {
1309 + update_option( self::BASELINE_REDACTION_OPTION, VIGILANTE_VERSION, false );
1310 + }
1311 + }
1312 +
1313 + /**
1314 + * Sweep the whole network once, so it does not wait for each site's cron
1315 + *
1316 + * The per-site cleanup above reaches a site when that site runs a scan or
1317 + * someone opens its dashboard, which on a quiet subsite can take a while.
1318 + * This walks every site once and gets it over with, and its marker is a
1319 + * network option so it does not repeat per site.
1320 + *
1321 + * Runs only on the main site of the network, where a network administrator
1322 + * works, and only there does it cost anything.
1323 + *
1324 + * @since 2.11.3
1325 + */
1326 + public function maybe_sweep_network_baselines() {
1327 + if ( ! is_multisite() || ! is_main_site() ) {
1328 + return;
1329 + }
1330 +
1331 + /*
1332 + * A network administrator, and nobody else. admin_init fires for any
1333 + * logged-in visitor, a subscriber opening their own profile included,
1334 + * and this walks every site of the network writing to each one. What it
1335 + * removes is stale data that rebuilds itself, so the harm is small, but
1336 + * an action over the whole network belongs to whoever administers the
1337 + * network. The surface inventory cannot see this: it reads wp_ajax_*,
1338 + * admin_post_* and REST routes, and a hook on admin_init is outside its
1339 + * coverage by construction, which is exactly the blind spot written
1340 + * down as rule 23.
1341 + *
1342 + * The per-site cleanup is deliberately not gated the same way: it also
1343 + * runs from the scan, under wp-cron with no user at all, and it only
1344 + * touches the site it runs on.
1345 + */
1346 + if ( ! current_user_can( 'manage_network_options' ) ) {
1347 + return;
1348 + }
1349 +
1350 + $this->maybe_sweep_network_results();
1351 +
1352 + $marker = get_site_option( self::BASELINE_SWEEP_OPTION );
1353 +
1354 + /*
1355 + * Two markers, because there are two different things to remember and
1356 + * 2.11.3 only remembered one of them.
1357 + *
1358 + * The walk is marked BEFORE it starts, on purpose: on a very large
1359 + * network it may not finish inside one request, and repeating it on
1360 + * every admin page load would be worse than leaving the rest to each
1361 + * site's own scan. But 2.11.3 wrote VIGILANTE_VERSION there, so an
1362 + * interrupted walk was retried by the next release, which was the only
1363 + * thing that ever finished it. Writing a fixed literal instead, as the
1364 + * first draft of 2.11.4 did, stopped the pointless rearming and took
1365 + * that retry away with it: a walk cut short would never be resumed by
1366 + * any version. And "each site's own scan cleans the rest" only holds
1367 + * where the module is on; with it off, the cleanup is registered under
1368 + * is_admin() alone, so a subsite nobody opens is exactly what the sweep
1369 + * exists for. Found by a cross review on 10 sep 2026.
1370 + *
1371 + * So: the running version means "started here and did not finish", and
1372 + * the migration literal means "finished, never again".
1373 + */
1374 + if ( self::BASELINE_SWEEP_MIGRATION === $marker ) {
1375 + return;
1376 + }
1377 +
1378 + if ( VIGILANTE_VERSION === $marker ) {
1379 + return;
1380 + }
1381 +
1382 + update_site_option( self::BASELINE_SWEEP_OPTION, VIGILANTE_VERSION );
1383 +
1384 + // Only this network. WP_Site_Query filters by network solely when
1385 + // network_id is given, so on a multi-network install the walk would
1386 + // otherwise reach the sites of other networks and promote their copies
1387 + // into this network's record (get_current_network_id() below does not
1388 + // change with switch_to_blog()).
1389 + $site_ids = get_sites(
1390 + array(
1391 + 'fields' => 'ids',
1392 + 'number' => 0,
1393 + 'network_id' => get_current_network_id(),
1394 + 'update_site_meta_cache' => false,
1395 + )
1396 + );
1397 +
1398 + $pending = 0;
1399 +
1400 + foreach ( $site_ids as $site_id ) {
1401 + switch_to_blog( $site_id );
1402 +
1403 + $per_site = get_option( self::BASELINE_OPTION, null );
1404 +
1405 + if ( null !== $per_site ) {
1406 + // Same care as the per-site cleanup, and the same helper, so
1407 + // the two paths cannot drift apart the way they nearly did.
1408 + if ( $this->promote_per_site_baseline( $per_site ) ) {
1409 + delete_option( self::BASELINE_OPTION );
1410 + } elseif ( is_array( $per_site ) ) {
1411 + $this->redact_in_place( $per_site );
1412 + $pending++;
1413 + }
1414 + }
1415 +
1416 + restore_current_blog();
1417 + }
1418 +
1419 + // Finished, and with nothing left behind, so it never has to run again
1420 + // in any version. A site whose copy could not be promoted keeps the
1421 + // marker on the running version instead, which is what gets the walk
1422 + // retried by the next release.
1423 + if ( ! $pending ) {
1424 + update_site_option( self::BASELINE_SWEEP_OPTION, self::BASELINE_SWEEP_MIGRATION );
1425 + }
1426 + }
1427 +
1428 + /**
86 1429 * Critical root files to monitor against a stored baseline.
87 1430 * These files have no official WordPress.org checksum because their
88 1431 * content is unique per installation.
89 1432 *
@@ -212,9 +1555,24 @@
212 1555 $this->activity_log = $activity_log;
213 1556 $this->options = $settings ? $settings->get_section( 'file_integrity' ) : array();
214 1557 $this->wp_version = get_bloginfo( 'version' );
215 1558 $this->ignored_files = get_option( 'vigilante_ignored_files', array() );
1559 + }
216 1560
1561 + /**
1562 + * Register the hooks of the scanner itself
1563 + *
1564 + * Until 2.11.4 all of this lived in the constructor, and the constructor is
1565 + * called from a dozen places: the module gate, the activator, the hook that
1566 + * runs after Vigilant writes a watched file, and the admin handlers that
1567 + * only want the class as a tool. Every
1568 + * one of them registered these hooks again, and one runs during admin_init
1569 + * itself. Registering apart from constructing means a `new` is only a
1570 + * `new`, and it is what lets the cleanup below stand on its own.
1571 + *
1572 + * @since 2.11.4
1573 + */
1574 + public function init_hooks() {
217 1575 // Schedule automated scans only if options available
218 1576 if ( ! empty( $this->options['auto_scan'] ) ) {
219 1577 add_action( 'vigilante_file_integrity_scan', array( $this, 'run_scheduled_scan' ) );
220 1578 $this->schedule_scan();
@@ -229,8 +1587,35 @@
229 1587 add_action( 'vigilante_fi_postupdate_verify', array( $this, 'run_postupdate_verify' ) );
230 1588 }
231 1589
232 1590 /**
1591 + * Register the cleanup of what earlier versions stored, module on or off
1592 + *
1593 + * These two are not integrity monitoring. They take out of the database
1594 + * something the plugin stored and should not have, which is the copy of
1595 + * wp-config.php carrying the database password and the eight keys and
1596 + * salts. Whoever switched the module off did not decide to keep that, and
1597 + * for that person the cleanup matters more, not less: they are not going
1598 + * to pass through the scanner again.
1599 + *
1600 + * Until 2.11.4 these were registered in the constructor, so they only ran
1601 + * where the module was on. A site with the module off kept the credentials
1602 + * with 2.11.3 installed, and a network whose main site had it off lost the
1603 + * sweep too, which was the one path that reached the sites nobody visits.
1604 + * Reported by @calzbert after reading the 2.11.3 diff.
1605 + *
1606 + * On admin_init because that is where the baseline is looked at, and it
1607 + * does one option read per admin request until it has run once.
1608 + *
1609 + * @since 2.11.4
1610 + */
1611 + public function init_cleanup_hooks() {
1612 + add_action( 'admin_init', array( $this, 'maybe_redact_stored_baseline' ) );
1613 + add_action( 'admin_init', array( $this, 'maybe_sweep_network_baselines' ) );
1614 + add_action( 'admin_init', array( $this, 'maybe_claim_owned_blocks' ) );
1615 + }
1616 +
1617 + /**
233 1618 * Check if scan time limit has been exceeded
234 1619 *
235 1620 * @return bool True if time exceeded.
236 1621 */
@@ -927,8 +2312,19 @@
927 2312 *
928 2313 * @return array Array of modified file entries (same format as core modified).
929 2314 */
930 2315 private function scan_critical_root_files() {
2316 + // Before reading anything: the scan is the only thing that reaches
2317 + // every site of a network on its own, through wp-cron and front-end
2318 + // traffic. Hooking the cleanup to admin_init alone left every subsite
2319 + // whose dashboard nobody opens with its old copy of wp-config.php,
2320 + // credentials included, for as long as nobody visited it.
2321 + $this->maybe_redact_stored_baseline();
2322 +
2323 + // And claim the blocks already on disk before anything is compared,
2324 + // so the first scan after updating uses the rule that will stay.
2325 + $this->maybe_claim_owned_blocks();
2326 +
931 2327 $modified = array();
932 2328 $baseline = $this->get_critical_files_baseline();
933 2329 $baseline_changed = false;
934 2330 $root_path = untrailingslashit( ABSPATH );
@@ -952,9 +2348,9 @@
952 2348 // First time seeing this file — store baseline silently
953 2349 $baseline[ $filename ] = array(
954 2350 'hash' => $current_hash,
955 2351 'size' => strlen( $content ),
956 - 'content' => $normalized,
2352 + 'content' => $this->baseline_content( $filename, $normalized ),
957 2353 'updated' => time(),
958 2354 );
959 2355 $baseline_changed = true;
960 2356 continue;
@@ -961,34 +2357,85 @@
961 2357 }
962 2358
963 2359 // Upgrade legacy baseline entries that lack content (pre-diff format)
964 2360 if ( ! isset( $baseline[ $filename ]['content'] ) && $baseline[ $filename ]['hash'] === $current_hash ) {
965 - $baseline[ $filename ]['content'] = $normalized;
2361 + $baseline[ $filename ]['content'] = $this->baseline_content( $filename, $normalized );
966 2362 $baseline_changed = true;
967 2363 continue;
968 2364 }
969 2365
970 - // Compare against stored baseline
971 - if ( $baseline[ $filename ]['hash'] !== $current_hash ) {
2366 + /*
2367 + * The file has not changed, but the copy on record is not the copy
2368 + * that would be stored today: an entry written before 2.11.2 with
2369 + * the credentials in it, or one written before the redaction list
2370 + * grew. Rewrite it.
2371 + *
2372 + * Only when the hash matches, and that condition is the whole
2373 + * point: if the file HAD changed, this entry is the evidence of
2374 + * the change that the administrator still has to review, and
2375 + * rewriting it here would quietly destroy that evidence.
2376 + */
2377 + if ( $baseline[ $filename ]['hash'] === $current_hash ) {
2378 + $expected = $this->baseline_content( $filename, $normalized );
2379 +
2380 + if ( $expected !== $baseline[ $filename ]['content'] ) {
2381 + $baseline[ $filename ]['content'] = $expected;
2382 + $baseline_changed = true;
2383 + }
2384 +
2385 + continue;
2386 + }
2387 +
2388 + /*
2389 + * Everything from here down is the changed file, and only that: the
2390 + * branch above returns on every matching hash, so there is no third
2391 + * case and no condition left to test. It used to be wrapped in an
2392 + * `if` repeating the opposite comparison, which read as if some
2393 + * other path could reach this point. It could not. Flagged by
2394 + * @calzbert, and worth the two lines it costs to say so.
2395 + *
2396 + * Both sides go through the same redaction, or every credential
2397 + * line would read as a change nobody made.
2398 + */
2399 + /*
2400 + * Where the shared files do not belong to this site, the lines are
2401 + * not computed at all. The diff is shown to whoever can open this
2402 + * site's screen, the administrator of a subsite included, and it
2403 + * was stored in the options of every subsite with the module on.
2404 + * Found by the audit of the admin surface for 2.11.8. The change is
2405 + * still reported, with both sizes, and the lines are read on the
2406 + * main site, where the change is approved.
2407 + */
2408 + if ( ! Vigilante_Settings::owns_shared_files() ) {
2409 + $diff = self::network_only_diff();
2410 + } else {
972 2411 $baseline_content = $baseline[ $filename ]['content'] ?? '';
973 - $diff = '' !== $baseline_content
974 - ? $this->compute_simple_diff( $baseline_content, $normalized )
2412 + $current_content = $this->baseline_content( $filename, $normalized );
2413 + $diff = ( '' !== $baseline_content && '' !== $current_content )
2414 + ? $this->compute_simple_diff( $baseline_content, $current_content )
975 2415 : array( 'added' => array(), 'removed' => array(), 'unavailable' => true );
976 2416
977 - $modified[] = array(
978 - 'file' => $filename,
979 - 'type' => 'critical_config',
980 - 'expected_hash' => $baseline[ $filename ]['hash'],
981 - 'actual_hash' => $current_hash,
982 - 'baseline_size' => $baseline[ $filename ]['size'],
983 - 'current_size' => strlen( $content ),
984 - 'diff' => $diff,
985 - );
2417 + // Say why there are no lines when today's copy could not be
2418 + // made safe, which approving does not change: the generic
2419 + // message talks about an old baseline. Cross review of 2.11.8.
2420 + if ( '' === $current_content && 'wp-config.php' === $filename ) {
2421 + $diff['redaction'] = true;
2422 + }
986 2423 }
2424 +
2425 + $modified[] = array(
2426 + 'file' => $filename,
2427 + 'type' => 'critical_config',
2428 + 'expected_hash' => $baseline[ $filename ]['hash'],
2429 + 'actual_hash' => $current_hash,
2430 + 'baseline_size' => $baseline[ $filename ]['size'],
2431 + 'current_size' => strlen( $content ),
2432 + 'diff' => $diff,
2433 + );
987 2434 }
988 2435
989 2436 if ( $baseline_changed ) {
990 - update_option( self::BASELINE_OPTION, $baseline, false );
2437 + $this->write_baseline( $baseline );
991 2438 }
992 2439
993 2440 return $modified;
994 2441 }
@@ -1060,30 +2507,49 @@
1060 2507 * @param string $filename File name (e.g. 'wp-config.php').
1061 2508 * @param string $content Raw file content.
1062 2509 * @return string Normalized content for hashing.
1063 2510 */
1064 - private function normalize_critical_file( $filename, $content ) {
2511 + private function normalize_critical_file( $filename, $content, $drop_all_original = false ) {
1065 2512 // Normalize line endings first (CRLF and CR to LF)
1066 2513 $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
1067 2514
2515 + /*
2516 + * Vigilant's own blocks are left out of the hash, so rewriting them is
2517 + * not reported as somebody else's change. Until 2.11.5 that covered
2518 + * everything between the markers, and every line carrying the
2519 + * [VIGILANTE_ORIGINAL] marker, whatever they contained. From 2.11.5 a
2520 + * block is left out only if it is exactly a block Vigilant wrote (see
2521 + * remember_owned_block()), and a marked line only while uncommenting it
2522 + * would still give a harmless define() (see is_vigilant_original_line()).
2523 + *
2524 + * Until the blocks already on disk have been claimed, the old rule
2525 + * applies unchanged. That is what keeps an update from changing the
2526 + * hash of a file nobody touched.
2527 + */
2528 + $claimed = $this->owned_blocks_claimed();
2529 +
1068 2530 if ( 'wp-config.php' === $filename ) {
1069 - // Remove Vigilante constants blocks (current and legacy)
2531 + // Vigilante constants blocks (current and legacy)
1070 2532 foreach ( $this->wpconfig_markers as $markers ) {
1071 - $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '\s*/s';
1072 - $content = preg_replace( $pattern, '', $content );
2533 + $content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed );
1073 2534 }
1074 2535
1075 - // Remove lines commented out by Vigilante (original constants)
1076 - $content = preg_replace(
2536 + // Lines commented out by Vigilante (original constants)
2537 + $content = preg_replace_callback(
1077 2538 '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m',
1078 - '',
2539 + function ( $line ) use ( $claimed, $drop_all_original ) {
2540 + // $drop_all_original reproduce la regla anterior a la 2.11.5 (quitar
2541 + // toda linea marcada) sobre los bloques de la regla nueva. Solo lo usa
2542 + // el re-base de la transicion, para decidir si la unica diferencia con
2543 + // el registro aprobado son estas lineas. Ver rebase_original_line_shift().
2544 + return ( ! $claimed || $drop_all_original || $this->is_vigilant_original_line( $line[0] ) ) ? '' : $line[0];
2545 + },
1079 2546 $content
1080 2547 );
1081 2548 } elseif ( '.htaccess' === $filename ) {
1082 - // Remove Vigilante htaccess blocks (firewall + security headers)
2549 + // Vigilante htaccess blocks (firewall + security headers)
1083 2550 foreach ( $this->htaccess_markers as $markers ) {
1084 - $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '\s*/s';
1085 - $content = preg_replace( $pattern, '', $content );
2551 + $content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed );
1086 2552 }
1087 2553 }
1088 2554
1089 2555 // Collapse multiple blank lines into one (blocks removal leaves gaps)
@@ -1092,15 +2558,380 @@
1092 2558 return trim( $content );
1093 2559 }
1094 2560
1095 2561 /**
2562 + * Leave Vigilant's blocks for one pair of markers out of the content
2563 + *
2564 + * Before the claim, every block, as it always was. After it, only the blocks
2565 + * whose fingerprint was recorded when Vigilant wrote them. A block that does
2566 + * not match, edited or planted, stays in the content: it counts in the hash
2567 + * and shows up in the diff.
2568 + *
2569 + * The match runs from marker to marker and the removal also takes the
2570 + * whitespace after the block, exactly as before, so a file whose blocks are
2571 + * all Vigilant's normalizes to the same text under both rules.
2572 + *
2573 + * @since 2.11.5
2574 + *
2575 + * @param string $filename 'wp-config.php' or '.htaccess'.
2576 + * @param string $content Content with normalized line endings.
2577 + * @param array $markers Start and end marker.
2578 + * @param bool $claimed Whether the claim has run.
2579 + * @return string
2580 + */
2581 + private function strip_vigilant_blocks( $filename, $content, $markers, $claimed ) {
2582 + $pattern = '/(' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . ')\s*/s';
2583 +
2584 + if ( ! $claimed ) {
2585 + return preg_replace( $pattern, '', $content );
2586 + }
2587 +
2588 + return preg_replace_callback(
2589 + $pattern,
2590 + function ( $match ) use ( $filename ) {
2591 + return self::is_owned_block( $filename, $match[1] ) ? '' : $match[0];
2592 + },
2593 + $content
2594 + );
2595 + }
2596 +
2597 + /**
2598 + * Whether a line carrying the original-constant marker is one Vigilant wrote
2599 + *
2600 + * comment_existing_constants() puts the marker in front of a define() of a
2601 + * constant it manages, and uncomment_original_constants() takes it away
2602 + * again whenever the constants are applied or removed, so whatever follows
2603 + * the marker gets to run some day. The line is left out of the hash only
2604 + * when there is nothing but indentation before the marker, nothing after it
2605 + * but a harmless define() and at most a line comment, and no PHP tag
2606 + * anywhere on it. That keeps it a comment today and harmless once
2607 + * uncommented. Anything else counts, and shows up in the diff.
2608 + *
2609 + * @since 2.11.5
2610 + *
2611 + * @param string $line One line of wp-config.php.
2612 + * @return bool
2613 + */
2614 + private function is_vigilant_original_line( $line ) {
2615 + if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) {
2616 + return false;
2617 + }
2618 +
2619 + return 1 === preg_match(
2620 + '/^[ \t]*' . preg_quote( $this->wpconfig_original_marker, '/' ) . self::harmless_define_pattern() . '[ \t]*(?:(?:\/\/|#(?!\[)).*)?$/',
2621 + $line
2622 + );
2623 + }
2624 +
2625 + /**
2626 + * A define() that runs nothing but itself, as a regular expression fragment
2627 + *
2628 + * The name is one of the constants Vigilant has managed in any version. The
2629 + * value is made only of literals (true, false, null, a number, a quoted
2630 + * string with nothing to interpolate) and of ABSPATH, WP_CONTENT_DIR and
2631 + * __DIR__, which is what a debug log path is usually built from, joined
2632 + * with dots. No call, no variable, no backtick, no include.
2633 + *
2634 + * @since 2.11.5
2635 + *
2636 + * @return string Pattern without delimiters.
2637 + */
2638 + private static function harmless_define_pattern() {
2639 + $names = 'DISALLOW_FILE_EDIT|DISALLOW_FILE_MODS|FORCE_SSL_ADMIN|FORCE_SSL_LOGIN|WP_DEBUG|WP_DEBUG_LOG|WP_DEBUG_DISPLAY|SCRIPT_DEBUG|DISABLE_WP_CRON'
2640 + . '|WP_POST_REVISIONS|AUTOSAVE_INTERVAL|EMPTY_TRASH_DAYS|WP_MEMORY_LIMIT|WP_MAX_MEMORY_LIMIT|WP_AUTO_UPDATE_CORE|CONCATENATE_SCRIPTS';
2641 +
2642 + $value = '(?:(?i:true|false|null)|-?\d+|\'(?:[^\'\\\\]|\\\\.)*\'|"[^"\\\\$]*"|ABSPATH|WP_CONTENT_DIR|__DIR__)';
2643 +
2644 + return 'define\s*\(\s*[\'"](?:' . $names . ')[\'"]\s*,\s*' . $value . '(?:\s*\.\s*' . $value . ')*\s*\)\s*;';
2645 + }
2646 +
2647 + /**
2648 + * Whether a wp-config.php constants block can only be one Vigilant wrote
2649 + *
2650 + * Every version of generate_constants() has written the start marker on its
2651 + * own line, then comments, blank lines and define() calls, bare or wrapped
2652 + * in if ( ! defined() ), then the end marker on its own line. A block made
2653 + * only of those lines runs nothing but the defines, whatever version wrote
2654 + * it and whatever settings it was written with. One line of anything else,
2655 + * or a PHP tag on any line, and the block is not taken.
2656 + *
2657 + * @since 2.11.5
2658 + *
2659 + * @param string $block Block from start marker to end marker, inclusive.
2660 + * @param array $markers Start and end marker.
2661 + * @return bool
2662 + */
2663 + private static function is_harmless_constants_block( $block, $markers ) {
2664 + $lines = explode( "\n", str_replace( array( "\r\n", "\r" ), "\n", (string) $block ) );
2665 +
2666 + if ( count( $lines ) < 2
2667 + || rtrim( array_shift( $lines ), " \t" ) !== $markers[0]
2668 + || ltrim( array_pop( $lines ), " \t" ) !== $markers[1]
2669 + ) {
2670 + return false;
2671 + }
2672 +
2673 + $define = self::harmless_define_pattern();
2674 + $guarded = '/^if\s*\(\s*!\s*defined\s*\(\s*[\'"][A-Z_]+[\'"]\s*\)\s*\)\s*\{\s*' . $define . '\s*\}$/';
2675 +
2676 + foreach ( $lines as $line ) {
2677 + $line = trim( $line, " \t" );
2678 +
2679 + if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) {
2680 + return false;
2681 + }
2682 +
2683 + if ( '' === $line
2684 + || 0 === strpos( $line, '//' )
2685 + || preg_match( '/^' . $define . '$/', $line )
2686 + || preg_match( $guarded, $line )
2687 + ) {
2688 + continue;
2689 + }
2690 +
2691 + return false;
2692 + }
2693 +
2694 + return true;
2695 + }
2696 +
2697 + /**
2698 + * Take ownership of the blocks already on disk, once
2699 + *
2700 + * Fingerprints are recorded when Vigilant writes a block, which leaves every
2701 + * block written before 2.11.5 without one. This records the blocks that can
2702 + * be recognised as Vigilant's without having seen them written:
2703 + *
2704 + * - A .htaccess block, when it is exactly what Vigilant would write today
2705 + * with the settings it has, the timestamp and the version apart. After an
2706 + * update, maybe_sync_server_files() rewrites those blocks on the next
2707 + * request, so the claim waits for it. Unless it has already failed: a
2708 + * block Vigilant cannot rewrite is not going to start matching, and
2709 + * waiting for it would keep the old rule for good.
2710 + * - A wp-config.php constants block, when every line in it is a comment, a
2711 + * blank line or a harmless define(). Nothing rewrites that block on an
2712 + * update and its format has changed five times, so comparing it with
2713 + * today's output would report every site that has not saved those
2714 + * settings since. A line that could run anything is never accepted.
2715 + *
2716 + * A block that is not recognised stays in the hash and is reported as a
2717 + * change, so the owner gets to look at it, and the activity log says why.
2718 + * Nothing in the stored baseline is rewritten.
2719 + *
2720 + * Only where the shared files belong, a single site or the main site of a
2721 + * network, because the expected blocks come from that site's settings. Until
2722 + * it has run, normalize_critical_file() keeps the old rule on every site.
2723 + *
2724 + * @since 2.11.5
2725 + */
2726 + public function maybe_claim_owned_blocks() {
2727 + if ( $this->owned_blocks_claimed() || ! Vigilante_Settings::owns_shared_files() ) {
2728 + return;
2729 + }
2730 +
2731 + $sync_due = get_option( 'vigilante_server_files_pending' )
2732 + || VIGILANTE_VERSION !== get_option( 'vigilante_server_files_version' );
2733 +
2734 + if ( $sync_due && ! get_option( 'vigilante_server_files_retry_after' ) ) {
2735 + return;
2736 + }
2737 +
2738 + $root_path = untrailingslashit( ABSPATH );
2739 + $expected = null;
2740 + $unclaimed = array();
2741 +
2742 + foreach ( $this->critical_root_files as $filename ) {
2743 + $full_path = $root_path . '/' . $filename;
2744 +
2745 + if ( ! file_exists( $full_path ) ) {
2746 + continue;
2747 + }
2748 +
2749 + $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2750 +
2751 + if ( false === $content ) {
2752 + // Unreadable right now: leave the claim open and try again later.
2753 + return;
2754 + }
2755 +
2756 + $content = str_replace( array( "\r\n", "\r" ), "\n", $content );
2757 + $is_config = 'wp-config.php' === $filename;
2758 +
2759 + foreach ( ( $is_config ? $this->wpconfig_markers : $this->htaccess_markers ) as $markers ) {
2760 + $pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '/s';
2761 +
2762 + if ( ! preg_match_all( $pattern, $content, $found ) ) {
2763 + continue;
2764 + }
2765 +
2766 + foreach ( $found[0] as $block ) {
2767 + if ( $is_config ) {
2768 + $ours = self::is_harmless_constants_block( $block, $markers );
2769 + } else {
2770 + $expected = null === $expected ? $this->expected_htaccess_blocks() : $expected;
2771 + $ours = isset( $expected[ $markers[0] ] )
2772 + && self::comparable_block( $block ) === self::comparable_block( $expected[ $markers[0] ] );
2773 + }
2774 +
2775 + if ( $ours ) {
2776 + self::remember_owned_block( $filename, $markers[0], $block, false );
2777 + } else {
2778 + $unclaimed[ $filename ] = $filename;
2779 + }
2780 + }
2781 + }
2782 +
2783 + // The commented-out originals are judged line by line at scan time.
2784 + // Looking at them here only keeps the log entry below complete.
2785 + if ( $is_config && preg_match_all( '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m', $content, $marked ) ) {
2786 + foreach ( $marked[0] as $line ) {
2787 + if ( ! $this->is_vigilant_original_line( $line ) ) {
2788 + $unclaimed[ $filename ] = $filename;
2789 + }
2790 + }
2791 + }
2792 + }
2793 +
2794 + update_site_option( self::OWNED_BLOCKS_CLAIM_OPTION, self::OWNED_BLOCKS_CLAIMED );
2795 +
2796 + // With the claim in place normalize uses the new rule, so a file nobody
2797 + // touched whose only difference is an original line the old rule dropped
2798 + // would read as changed. Re-base those, and only those, once.
2799 + $this->rebase_original_line_shift();
2800 +
2801 + if ( $unclaimed && $this->activity_log ) {
2802 + $this->activity_log->log(
2803 + 'file',
2804 + 'critical_file_unrecognized_block',
2805 + sprintf(
2806 + /* translators: %s: comma-separated file names, such as wp-config.php or .htaccess. */
2807 + __( 'Content marked as written by Vigilant in %s does not match what Vigilant writes. From now on it is checked like the rest of the file, so the file integrity scan reports it as a change for you to review.', 'vigilante' ),
2808 + implode( ', ', $unclaimed )
2809 + ),
2810 + array( 'files' => array_values( $unclaimed ) ),
2811 + 'warning'
2812 + );
2813 + }
2814 + }
2815 +
2816 + /**
2817 + * Re-base the critical files whose only change is a newly kept original line
2818 + *
2819 + * Until 2.11.5 the hash left out every [VIGILANTE_ORIGINAL] line; from 2.11.5
2820 + * it keeps the ones whose value is not a plain constant define, which is the
2821 + * right thing for the hash but moves it on a file nobody edited: the stored
2822 + * baseline was taken under the old rule, and nothing re-bases wp-config.php on
2823 + * an update (maybe_sync_server_files() only rewrites the .htaccess). So the
2824 + * first scan after updating would report wp-config.php as changed.
2825 + *
2826 + * This runs once, in the same pass that claims the blocks. For each file it
2827 + * re-bases to the new hash only when the baseline still matches the file with
2828 + * every original line dropped, which means the blocks are exactly the approved
2829 + * ones and the sole difference is those lines, the user's own commented-out
2830 + * defines. A block that was edited or planted does not match with the lines
2831 + * dropped, so it is left to be reported: this closes the false positive
2832 + * without adopting anything that was hidden before.
2833 + *
2834 + * @since 2.11.5
2835 + */
2836 + private function rebase_original_line_shift() {
2837 + $baseline = $this->get_critical_files_baseline();
2838 + $root = untrailingslashit( ABSPATH );
2839 + $changed = false;
2840 +
2841 + foreach ( $this->critical_root_files as $filename ) {
2842 + $full_path = $root . '/' . $filename;
2843 +
2844 + if ( ! file_exists( $full_path ) || empty( $baseline[ $filename ]['hash'] ) ) {
2845 + continue;
2846 + }
2847 +
2848 + $content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2849 +
2850 + if ( false === $content ) {
2851 + continue;
2852 + }
2853 +
2854 + $current = md5( $this->normalize_critical_file( $filename, $content ) );
2855 +
2856 + // Already in step, or a real change to something other than the
2857 + // original lines: nothing to re-base here.
2858 + if ( $baseline[ $filename ]['hash'] === $current
2859 + || $baseline[ $filename ]['hash'] !== md5( $this->normalize_critical_file( $filename, $content, true ) )
2860 + ) {
2861 + continue;
2862 + }
2863 +
2864 + $normalized = $this->normalize_critical_file( $filename, $content );
2865 + $baseline[ $filename ]['hash'] = $current;
2866 + $baseline[ $filename ]['size'] = strlen( $content );
2867 + $baseline[ $filename ]['content'] = $this->baseline_content( $filename, $normalized );
2868 + $baseline[ $filename ]['updated'] = time();
2869 + $changed = true;
2870 + }
2871 +
2872 + if ( $changed ) {
2873 + $this->write_baseline( $baseline );
2874 + }
2875 + }
2876 +
2877 + /**
2878 + * The .htaccess blocks Vigilant would write today, keyed by start marker
2879 + *
2880 + * @since 2.11.5
2881 + *
2882 + * @return array
2883 + */
2884 + private function expected_htaccess_blocks() {
2885 + $settings = $this->settings ? $this->settings : new Vigilante_Settings();
2886 +
2887 + $classes = array(
2888 + 'Vigilante_Htaccess_Protection' => 'class-htaccess-protection.php',
2889 + 'Vigilante_Security_Headers' => 'class-security-headers.php',
2890 + );
2891 +
2892 + foreach ( $classes as $class => $file ) {
2893 + if ( ! class_exists( $class ) ) {
2894 + require_once VIGILANTE_INCLUDES_DIR . $file;
2895 + }
2896 + }
2897 +
2898 + $headers = new Vigilante_Security_Headers( $settings );
2899 +
2900 + return array(
2901 + Vigilante_Htaccess_Protection::MARKER_START => ( new Vigilante_Htaccess_Protection( $settings ) )->generate_rules(),
2902 + Vigilante_Security_Headers::MARKER_START => Vigilante_Security_Headers::MARKER_START . "\n" . $headers->generate_rules_content() . "\n" . Vigilante_Security_Headers::MARKER_END,
2903 + );
2904 + }
2905 +
2906 + /**
2907 + * A .htaccess block with the parts that change on every write evened out
2908 + *
2909 + * Two blocks Vigilant wrote with the same settings differ only in the time
2910 + * they were generated and, across an update, in the version the firewall
2911 + * block names. Everything else has to be identical for the claim to take
2912 + * the block.
2913 + *
2914 + * @since 2.11.5
2915 + *
2916 + * @param string $block Block from start marker to end marker, inclusive.
2917 + * @return string
2918 + */
2919 + private static function comparable_block( $block ) {
2920 + $block = str_replace( array( "\r\n", "\r" ), "\n", (string) $block );
2921 + $block = preg_replace( '/^# Generated: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/m', '# Generated:', $block );
2922 + $block = preg_replace( '/^# Vigilante for WordPress - Firewall v[0-9][0-9A-Za-z.\-]*$/m', '# Vigilante for WordPress - Firewall v', $block );
2923 +
2924 + return rtrim( $block, "\n" );
2925 + }
2926 +
2927 + /**
1096 2928 * Get stored baseline hashes for critical files
1097 2929 *
1098 2930 * @return array Associative array keyed by filename.
1099 2931 */
1100 2932 public function get_critical_files_baseline() {
1101 - $baseline = get_option( self::BASELINE_OPTION, array() );
1102 - return is_array( $baseline ) ? $baseline : array();
2933 + return $this->read_baseline();
1103 2934 }
1104 2935
1105 2936 /**
1106 2937 * Update baseline hash for a single critical file
@@ -1125,16 +2956,44 @@
1125 2956
1126 2957 $normalized = $this->normalize_critical_file( $filename, $content );
1127 2958
1128 2959 $baseline = $this->get_critical_files_baseline();
2960 +
2961 + /*
2962 + * No guard here, and there was one for a few hours during 2.11.4 that
2963 + * had to come out. It refused to rewrite the record when the stored hash
2964 + * no longer matched the file, meant to stop a write of ours from
2965 + * approving somebody else's pending edit. Two things were wrong with it,
2966 + * both measured on 10 sep 2026 by a third cross review:
2967 + *
2968 + * - This is also the Approve button (Vigilante_Admin_Ajax::
2969 + * ajax_approve_critical_file). A moved hash is exactly the state in
2970 + * which Approve is pressed, so the guard made Approve fail every time
2971 + * and the warning could never be closed.
2972 + * - Its premise, "our own write cannot move the normalized hash", holds
2973 + * for the block and not for the rest of what the writers do.
2974 + * comment_existing_constants() turns a define() into a
2975 + * [VIGILANTE_ORIGINAL] line that normalize_critical_file() leaves as
2976 + * an empty line, and remove_old_rules() deletes legacy .htaccess blocks
2977 + * that normalize_critical_file() does not know. Both move the hash, so
2978 + * the guard would have raised a false "file modified" after Vigilant's
2979 + * own work, on sites that updated.
2980 + *
2981 + * The real fix is to know what the hash was before WE touched the file:
2982 + * the writers capture it and pass it along vigilante_critical_file_
2983 + * written, and this compares against that instead of against the
2984 + * record. Until then this behaves as it always has, which does mean a
2985 + * write of ours can adopt a third-party edit that was pending review.
2986 + * That is pre-existing, and written down in the roadmap.
2987 + */
1129 2988 $baseline[ $filename ] = array(
1130 2989 'hash' => md5( $normalized ),
1131 2990 'size' => strlen( $content ),
1132 - 'content' => $normalized,
2991 + 'content' => $this->baseline_content( $filename, $normalized ),
1133 2992 'updated' => time(),
1134 2993 );
1135 2994
1136 - return update_option( self::BASELINE_OPTION, $baseline, false );
2995 + return $this->write_baseline( $baseline );
1137 2996 }
1138 2997
1139 2998 /**
1140 2999 * Regenerate baseline for all critical files
@@ -1162,14 +3021,14 @@
1162 3021 $normalized = $this->normalize_critical_file( $filename, $content );
1163 3022 $baseline[ $filename ] = array(
1164 3023 'hash' => md5( $normalized ),
1165 3024 'size' => strlen( $content ),
1166 - 'content' => $normalized,
3025 + 'content' => $this->baseline_content( $filename, $normalized ),
1167 3026 'updated' => time(),
1168 3027 );
1169 3028 }
1170 3029
1171 - update_option( self::BASELINE_OPTION, $baseline, false );
3030 + $this->write_baseline( $baseline );
1172 3031
1173 3032 return $baseline;
1174 3033 }
1175 3034
@@ -2496,8 +4355,21 @@
2496 4355 return array_values(
2497 4356 array_filter(
2498 4357 $items,
2499 4358 function ( $item ) {
4359 + /*
4360 + * On a network, wp-config.php and the root .htaccess are not a
4361 + * site's to silence: a change to them is closed by approving it,
4362 + * and approving takes a network administrator since 2.11.3. The
4363 + * ignore list is an option of each site, so until 2.11.8 the
4364 + * administrator of the main site without network rights hid a
4365 + * pending change from the network administrator's own screen by
4366 + * posting the file name to the ignore handler.
4367 + */
4368 + if ( is_multisite() && is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) {
4369 + return true;
4370 + }
4371 +
2500 4372 $file = is_array( $item ) && isset( $item['file'] ) ? $item['file'] : '';
2501 4373 return ! in_array( $file, $this->ignored_files, true );
2502 4374 }
2503 4375 )
@@ -2763,9 +4635,9 @@
2763 4635 }
2764 4636
2765 4637 // CTA button
2766 4638 $inner .= Vigilante_Email_Template::button(
2767 - admin_url( 'admin.php?page=vigilante&tab=file-integrity' ),
4639 + admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-last-scan' ),
2768 4640 __( 'Review in Vigilant', 'vigilante' )
2769 4641 );
2770 4642
2771 4643 $is_alert = ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 );