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
vigilante / includes / class-self-integrity.php

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

3,116 lines 140.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Vigilant Self-Integrity (self-protection core)
4 *
5 * Verifies Vigilant's own files with three anchors:
6 *
7 * A1 WordPress.org SHA-256 checksums for the installed version (remote
8 * anchor: a local attacker cannot alter it; unavailable for untagged
9 * builds or in the first hours after a release).
10 * A2 MANIFEST.sha256 distributed inside the plugin (offline anchor in GNU
11 * coreutils format, verifiable by anyone with `sha256sum -c` or
12 * `php bin/verify-manifest.php`; regenerable by whoever can write to the
13 * plugin folder, which is why it never stands alone).
14 * A3 SHA-256 fingerprint of MANIFEST.sha256 stored in the database
15 * (detects a swapped or deleted manifest; forging it needs database
16 * write access).
17 *
18 * What this deliberately does NOT cover: vulnerabilities in Vigilant's own
19 * code (this detects tampering, not bugs), and an attacker with database
20 * write access, who can blank the fingerprint, as with any security plugin.
21 * There is no toggle to switch off: only the filter of is_on(), and using it
22 * is reported as critical. Both are documented in SECURITY.md.
23 *
24 * Entry points:
25 * - File Integrity scan (first block of run_scan(), exempt from the time
26 * budget and from user exclusions).
27 * - upgrader_process_complete (immediate verification after each update of
28 * Vigilant itself; see vigilante_on_upgrader_process_complete()).
29 * - admin_init, for a user who can manage options only: version change
30 * detection (priority 20) and the cron watchdog (priority 30).
31 * - daily maintenance (cron): the same two, for sites nobody opens the admin of.
32 *
33 * Events are logged with type "system", which the Audit Alerts engine
34 * ignores, so the standalone alert email here is never duplicated.
35 *
36 * @package Vigilante
37 * @since 3.0.0
38 */
39
40 // Prevent direct access
41 if ( ! defined( 'ABSPATH' ) ) {
42 exit;
43 }
44
45 /**
46 * Class Vigilante_Self_Integrity
47 *
48 * The constructor registers nothing: Vigilante_Main creates the one instance
49 * that calls init_hooks(), and every other new (activation, migration, scan,
50 * upgrader) is a plain object. It is NOT gated by the modules.file_integrity
51 * master toggle, and it has no setting of its own: see is_on().
52 */
53 class Vigilante_Self_Integrity {
54
55 /**
56 * Option holding the persistent state (autoload off; runtime data never
57 * lives inside vigilante_options).
58 */
59 const STATE_OPTION = 'vigilante_self_integrity_state';
60
61 /**
62 * Manifest file name, at the plugin root.
63 */
64 const MANIFEST_FILE = 'MANIFEST.sha256';
65
66 /**
67 * Transient prefix for the wp.org sha256 checksums cache (per version).
68 */
69 const CHECKSUMS_TRANSIENT_PREFIX = 'vigilante_self_checksums_';
70
71 /**
72 * Throttle transient for the watchdog run from admin_init.
73 */
74 const WATCHDOG_TRANSIENT = 'vigilante_watchdog_ran';
75
76 /**
77 * Throttle transient for the version change check. While a version change
78 * cannot be vouched for, nothing is rebaselined, so without it every
79 * admin page would hash the tree and ask WordPress.org again.
80 */
81 const VERSION_CHECK_TRANSIENT = 'vigilante_self_version_check';
82
83 /**
84 * Seconds between two version change checks while one is unresolved.
85 */
86 const VERSION_CHECK_THROTTLE = 600;
87
88 /**
89 * Per-request HTTP timeout in seconds. Kept low because the check can run
90 * in synchronous admin_init contexts.
91 */
92 const HTTP_TIMEOUT = 5;
93
94 /**
95 * A manifest with more lines than this, blank ones included, is not a
96 * Vigilant manifest.
97 */
98 const MAX_MANIFEST_LINES = 2000;
99
100 /**
101 * Largest MANIFEST.sha256 that is read at all. MAX_MANIFEST_LINES entries
102 * take well under a megabyte, so a bigger file is not a valid manifest,
103 * and reading it to find that out could exhaust memory and end the scan
104 * with no finding.
105 */
106 const MAX_MANIFEST_BYTES = 1048576;
107
108 /**
109 * Findings kept in the stored state and in log entries (worst first).
110 */
111 const MAX_STORED_FINDINGS = 200;
112
113 /**
114 * Harmless extra files listed at most; links, executables and folders
115 * that cannot be listed are always reported (see detect_extra_files()).
116 */
117 const MAX_EXTRA_WARNINGS = 100;
118
119 /**
120 * Largest file whose line endings are normalized before comparing; the
121 * biggest distributed file is under 600 KB.
122 */
123 const MAX_NORMALIZED_BYTES = 5242880;
124
125 /**
126 * Entries walked at most in the plugin folder before giving up with a
127 * critical finding. A distributed copy holds under a hundred.
128 */
129 const MAX_TREE_ENTRIES = 50000;
130
131 /**
132 * A cron event restored again within this window counts as repeated
133 * (30 days).
134 */
135 const WATCHDOG_REPEAT_WINDOW = 2592000;
136
137 /**
138 * How long after the last restoration a repeated one is still shown
139 * (7 days). After a week with no task missing the finding closes on its
140 * own: until 3.0.4 it stayed for the whole 30 days whatever the site did,
141 * while the screen asked for a new scan that could not clear it.
142 */
143 const WATCHDOG_RECENT_WINDOW = 604800;
144
145 /**
146 * Restorations of the same task inside WATCHDOG_RECENT_WINDOW that mean
147 * something is removing it over and over. Fewer than that is a warning:
148 * WordPress itself drops a recurring task when it cannot save its next run
149 * (wp-cron.php reschedules, and removes the old run even when that
150 * failed), so two in a month is not evidence of anybody doing anything.
151 */
152 const WATCHDOG_ACTIVE_COUNT = 3;
153
154 /**
155 * Restorations of the same task inside WATCHDOG_REPEAT_WINDOW that mean
156 * the same thing on a site whose administrator comes by once a week. The
157 * watchdog only runs when an administrator opens wp-admin or from the
158 * daily maintenance, which is itself one of the tasks being removed, so
159 * with the week alone such a site never got past the warning however long
160 * it went on. Found by the cross review of B77. Since the watchdog also
161 * runs from the scheduled tasks of WordPress itself (see
162 * run_watchdog_from_core_cron()) this is what tells apart something that
163 * removes the tasks once a week.
164 */
165 const WATCHDOG_MONTH_COUNT = 4;
166
167 /**
168 * A check older than this stops counting as a result. Something that stops
169 * the check (a filter, a removed hook, a cron nobody runs, files edited)
170 * leaves the last state frozen, and a green from three days ago read as
171 * "verified" would be the screen lying by omission.
172 */
173 const STALE_AFTER = 259200;
174
175 /**
176 * How often the same alarm about being switched off, or about a hook that
177 * someone removed, is written to the log and emailed.
178 */
179 const OFF_ALARM_WINDOW = 86400;
180
181 /**
182 * Settings instance
183 *
184 * @var Vigilante_Settings
185 */
186 private $settings;
187
188 /**
189 * Activity log instance
190 *
191 * @var Vigilante_Activity_Log|null
192 */
193 private $activity_log;
194
195 /**
196 * Files at the plugin root that are never in the manifest.
197 *
198 * The same rules live in bin/verify-manifest.php and in the release tool
199 * that generates the manifest, and the release checks run the three on the
200 * same set of paths and require the same verdicts.
201 *
202 * - MANIFEST.sha256: the manifest cannot contain its own hash.
203 * - readme.txt, changelog.txt: WordPress.org lets a readme be updated on
204 * a released tag without a new version, so hashing them would turn an
205 * ordinary readme fix into a tamper alarm. They are not code; the
206 * WordPress.org checksums still list them.
207 *
208 * Only at the root: a directory or a file with one of these names deeper
209 * in the tree is checked like any other.
210 *
211 * @var array
212 */
213 private static $root_excluded_files = array( 'MANIFEST.sha256', 'readme.txt', 'changelog.txt' );
214
215 /**
216 * SVN working copy metadata at the plugin root, matched exactly: only the
217 * files svn itself writes there. Anything else inside .svn/ is checked
218 * like any other file, so the folder cannot hide code (a .htaccess that
219 * maps .jpg to PHP next to a .jpg with code in it was enough before).
220 *
221 * @var string
222 */
223 private static $svn_metadata_pattern = '#^\.svn/(?:wc\.db|wc\.db-journal|format|entries|pristine/[0-9a-f]{2}/[0-9a-f]{40}\.svn-base)$#';
224
225 /**
226 * Operating system junk, skipped by file name anywhere in the tree.
227 *
228 * @var array
229 */
230 private static $junk_file_names = array( '.DS_Store', 'Thumbs.db' );
231
232 /**
233 * Extensions a web server may run as PHP: an extra file with one of these
234 * is critical, and a modified one too.
235 *
236 * @var array
237 */
238 private static $executable_extensions = array( 'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'php8', 'phps', 'pht', 'phar', 'inc' );
239
240 /**
241 * File names that change what the server runs in their folder (a handler
242 * for another extension, auto_prepend_file): treated as executable.
243 *
244 * @var array
245 */
246 private static $executable_names = array( '.htaccess', '.user.ini', 'php.ini' );
247
248 /**
249 * Scheduled tasks of WordPress itself that the watchdog also runs from.
250 *
251 * The update checks are scheduled twice a day and put back by core on
252 * every page load when they are missing (wp_schedule_update_checks(), on
253 * init); the two daily cleanups are put back from every admin page
254 * (wp-admin/admin.php). None of them depends on a task of Vigilant being
255 * there, which is the point.
256 *
257 * @var array
258 */
259 private static $core_tasks = array( 'wp_version_check', 'wp_update_plugins', 'wp_scheduled_delete', 'delete_expired_transients' );
260
261 /**
262 * Whether the watchdog already ran from a task of WordPress in this
263 * process: several of them fall due in the same cron run.
264 *
265 * @var bool
266 */
267 private $core_cron_pass_done = false;
268
269 /**
270 * Constructor. Registers no hooks, see init_hooks().
271 *
272 * @param Vigilante_Settings $settings Settings instance.
273 * @param Vigilante_Activity_Log|null $activity_log Activity log instance.
274 */
275 public function __construct( $settings, $activity_log = null ) {
276 $this->settings = $settings;
277 $this->activity_log = $activity_log;
278 }
279
280 /**
281 * Register the admin_init entry points. Called once, by Vigilante_Main.
282 *
283 * Priority 20 runs after Vigilante_Admin::run_migrations() (priority 10),
284 * so an anchor captured by the migration is not processed twice.
285 */
286 public function init_hooks() {
287 add_action( 'admin_init', array( $this, 'maybe_detect_version_change' ), 20 );
288 add_action( 'admin_init', array( $this, 'maybe_run_watchdog' ), 30 );
289 // Before the callback of WordPress itself, which runs at priority 10:
290 // the one of wp_version_check is what starts the background updates
291 // (wp_maybe_auto_update), and a check that ran right behind it found
292 // the new files of Vigilant with the old code still in memory.
293 foreach ( self::$core_tasks as $core_task ) {
294 add_action( $core_task, array( $this, 'run_watchdog_from_core_cron' ), 5 );
295 }
296 // Last of all, to see whether the two above are still there: code that
297 // runs inside WordPress can unhook them, and a plugin that does that
298 // leaves every screen showing the last result as if nothing happened.
299 add_action( 'admin_init', array( $this, 'verify_hooks' ), PHP_INT_MAX );
300 }
301
302 /**
303 * Are the entry points of the self-check still hooked? The two of
304 * admin_init, and the one that hangs from the scheduled tasks of WordPress.
305 *
306 * Runs at the end of admin_init, so anything that unhooked them earlier in
307 * the same request is visible here. It cannot say which file called
308 * remove_action (WordPress fires nothing when a callback is removed, and by
309 * the time this runs it already happened), so it records the short list of
310 * suspects: the plugins loaded in this request.
311 */
312 public function verify_hooks() {
313 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
314 return;
315 }
316
317 // Switched off by code: the line in the Security Audit is written from
318 // here too, not only from the daily task. Waiting for cron to say that
319 // the self-check is off is waiting a day to hear the alarm.
320 $this->audit_off_state();
321 if ( ! self::is_on() ) {
322 return;
323 }
324 $missing = array();
325 foreach ( array( 'maybe_detect_version_change', 'maybe_run_watchdog' ) as $method ) {
326 if ( false === has_action( 'admin_init', array( $this, $method ) ) ) {
327 $missing[] = $method;
328 }
329 }
330 // The entry point that does not depend on anybody opening wp-admin:
331 // gone from every task of WordPress it was hooked to.
332 $on_core = false;
333 foreach ( self::$core_tasks as $core_task ) {
334 if ( false !== has_action( $core_task, array( $this, 'run_watchdog_from_core_cron' ) ) ) {
335 $on_core = true;
336 break;
337 }
338 }
339 if ( ! $on_core ) {
340 $missing[] = 'run_watchdog_from_core_cron';
341 }
342
343 $state = $this->get_state();
344 if ( empty( $missing ) ) {
345 if ( ! empty( $state['hooks_removed'] ) ) {
346 unset( $state['hooks_removed'] );
347 $this->save_state( $state );
348 }
349 return;
350 }
351
352 $state['hooks_removed'] = array(
353 'methods' => $missing,
354 'last' => time(),
355 'plugins' => self::loaded_plugins(),
356 );
357 $this->save_state( $state );
358
359 $alerted = isset( $state['hooks_alerted'] ) ? (int) $state['hooks_alerted'] : 0;
360 if ( ( time() - $alerted ) < self::OFF_ALARM_WINDOW ) {
361 return;
362 }
363 $state['hooks_alerted'] = time();
364 $this->save_state( $state );
365 $this->log(
366 'self_hooks_removed',
367 sprintf(
368 /* translators: %s: comma-separated list of removed callbacks */
369 __( 'Something removed the self-protection hooks of Vigilant in this request: %s', 'vigilante' ),
370 implode( ', ', $missing )
371 ),
372 array(
373 'methods' => $missing,
374 'plugins' => self::loaded_plugins(),
375 ),
376 'critical'
377 );
378 $this->maybe_send_self_alert(
379 array(
380 $this->finding(
381 'self_hooks_removed',
382 implode( ', ', $missing ),
383 'critical',
384 __( 'Something removed the hooks that run the self-check.', 'vigilante' )
385 ),
386 ),
387 'hooks'
388 );
389 }
390
391 /**
392 * Plugins and must-use plugins loaded in this request: the short list of
393 * suspects when something turns the self-check off.
394 *
395 * @return array
396 */
397 public static function loaded_plugins() {
398 $lista = array();
399 if ( function_exists( 'wp_get_active_and_valid_plugins' ) ) {
400 foreach ( wp_get_active_and_valid_plugins() as $ruta ) {
401 $lista[] = str_replace( WP_PLUGIN_DIR . '/', '', $ruta );
402 }
403 }
404 if ( function_exists( 'wp_get_mu_plugins' ) ) {
405 foreach ( wp_get_mu_plugins() as $ruta ) {
406 $lista[] = 'mu-plugins/' . basename( $ruta );
407 }
408 }
409 sort( $lista, SORT_STRING );
410 return array_slice( $lista, 0, 60 );
411 }
412
413 /**
414 * Files that hook the filter which switches the self-check off.
415 *
416 * Named, not guessed: each callback is resolved with Reflection to the file
417 * that declares it. Turning the check off is allowed, doing it quietly is
418 * not.
419 *
420 * @return array List of file paths, relative to the WordPress root.
421 */
422 public static function disabled_by() {
423 global $wp_filter;
424 $ficheros = array();
425 if ( empty( $wp_filter['vigilante_self_integrity_enabled'] ) || ! is_object( $wp_filter['vigilante_self_integrity_enabled'] ) ) {
426 return $ficheros;
427 }
428 foreach ( (array) $wp_filter['vigilante_self_integrity_enabled']->callbacks as $prioridad => $entradas ) {
429 foreach ( (array) $entradas as $entrada ) {
430 $llamada = isset( $entrada['function'] ) ? $entrada['function'] : null;
431 try {
432 if ( is_string( $llamada ) && function_exists( $llamada ) ) {
433 $ref = new ReflectionFunction( $llamada );
434 } elseif ( $llamada instanceof Closure ) {
435 $ref = new ReflectionFunction( $llamada );
436 } elseif ( is_array( $llamada ) && isset( $llamada[0], $llamada[1] ) ) {
437 $ref = new ReflectionMethod( is_object( $llamada[0] ) ? get_class( $llamada[0] ) : (string) $llamada[0], (string) $llamada[1] );
438 } else {
439 continue;
440 }
441 } catch ( Exception $e ) {
442 unset( $e );
443 continue;
444 } catch ( Error $e ) {
445 unset( $e );
446 continue;
447 }
448 $fichero = $ref->getFileName();
449 if ( ! $fichero ) {
450 continue;
451 }
452 $fichero = str_replace( array( ABSPATH, WP_CONTENT_DIR . '/' ), array( '', 'wp-content/' ), $fichero );
453 if ( ! in_array( $fichero, $ficheros, true ) ) {
454 $ficheros[] = $fichero;
455 }
456 }
457 }
458 return $ficheros;
459 }
460
461 /**
462 * Whether the self-check runs. It always does.
463 *
464 * There is no setting for this, and that is the point: a security plugin
465 * that can be told not to check itself has a switch whose only real user is
466 * whoever just changed its files. It was a checkbox while this was being
467 * built, it never shipped as one, and it was removed before 3.0.0 was
468 * tagged. It is not gated by the modules.file_integrity master toggle
469 * either: turning off File Integrity does not stop the plugin from
470 * checking its own files.
471 *
472 * The filter is for the one honest case, a site that must not talk to
473 * WordPress.org at all, and it is documented in SECURITY.md. It lives in
474 * code, so nobody with write access to the database can use it.
475 *
476 * @return bool
477 */
478 public static function is_on() {
479 return (bool) apply_filters( 'vigilante_self_integrity_enabled', true );
480 }
481
482 /**
483 * Instance form of is_on(), kept because most callers have the object.
484 *
485 * @return bool
486 */
487 public function is_enabled() {
488 return self::is_on();
489 }
490
491 // -------------------------------------------------------------------
492 // Paths
493 // -------------------------------------------------------------------
494
495 /**
496 * Whether a manifest path is a plain relative path inside the plugin.
497 *
498 * The manifest can be written by whoever can write to the plugin folder,
499 * and every entry is hashed from disk: an absolute path, a ".." segment or
500 * a NUL byte would turn the check into a hash oracle for any file on the
501 * server. Only letters, digits, dot, underscore, hyphen, plus and at sign,
502 * separated by single slashes, are accepted.
503 *
504 * @param string $path Relative path.
505 * @return bool
506 */
507 public static function is_safe_relative_path( $path ) {
508 if ( ! is_string( $path ) || '' === $path || strlen( $path ) > 255 ) {
509 return false;
510 }
511 if ( ! preg_match( '#^[A-Za-z0-9._@+-]+(?:/[A-Za-z0-9._@+-]+)*$#', $path ) ) {
512 return false;
513 }
514 foreach ( explode( '/', $path ) as $segment ) {
515 if ( '.' === $segment || '..' === $segment ) {
516 return false;
517 }
518 }
519 return true;
520 }
521
522 /**
523 * Whether a relative path is left out of the manifest.
524 *
525 * @param string $relative Relative path with forward slashes.
526 * @return bool
527 */
528 public static function is_excluded_path( $relative ) {
529 $relative = (string) $relative;
530 $segments = explode( '/', $relative );
531 if ( 1 === count( $segments ) && in_array( $segments[0], self::$root_excluded_files, true ) ) {
532 return true;
533 }
534 if ( preg_match( self::$svn_metadata_pattern, $relative ) ) {
535 return true;
536 }
537 return in_array( end( $segments ), self::$junk_file_names, true );
538 }
539
540 /**
541 * Whether a relative path is something a web server may run, or a file
542 * that changes what it runs.
543 *
544 * @param string $relative Relative path.
545 * @return bool
546 */
547 public static function is_executable_path( $relative ) {
548 $name = strtolower( basename( (string) $relative ) );
549 if ( in_array( $name, self::$executable_names, true ) ) {
550 return true;
551 }
552 // Every extension counts, not only the last one: with AddHandler,
553 // Apache runs x.php.jpg as PHP (mod_mime maps each extension of the
554 // name), which is how several cPanel setups configure PHP.
555 $parts = explode( '.', $name );
556 array_shift( $parts );
557 foreach ( $parts as $part ) {
558 if ( in_array( $part, self::$executable_extensions, true ) ) {
559 return true;
560 }
561 }
562 return false;
563 }
564
565 /**
566 * Whether a path is a text file whose line endings a host may rewrite.
567 *
568 * @param string $relative Relative path.
569 * @return bool
570 */
571 public static function is_text_path( $relative ) {
572 return in_array(
573 strtolower( pathinfo( (string) $relative, PATHINFO_EXTENSION ) ),
574 array( 'php', 'js', 'css', 'json', 'txt', 'md', 'html', 'htm', 'xml', 'svg', 'po', 'pot', 'ini' ),
575 true
576 );
577 }
578
579 /**
580 * sha256 of a text file with CRLF or CR line endings turned into LF and,
581 * where it changes nothing, a leading UTF-8 BOM removed.
582 *
583 * Not a copy of Vigilante_File_Integrity::hash_matches_published(), which
584 * forgives more because it judges other plugins. Here a BOM is only
585 * forgiven in files that do not care about it: in PHP it is output sent
586 * before the headers, and json_decode() rejects it, so a BOM in front of
587 * includes/scan-patterns.json empties the malware signatures while the
588 * file would look intact. Files above MAX_NORMALIZED_BYTES are not read
589 * whole: the raw hash has already failed, and the difference stays a
590 * finding instead of a fatal error that stops the whole scan.
591 *
592 * @param string $path Absolute path.
593 * @param string $relative Relative path, for the extension.
594 * @return string|null
595 */
596 private static function normalized_hash( $path, $relative ) {
597 $size = filesize( $path );
598 if ( false === $size || $size > self::MAX_NORMALIZED_BYTES ) {
599 return null;
600 }
601 // The read stops past the limit too: the size can change between the
602 // check above and the read.
603 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file of the plugin itself, read to hash it.
604 $content = file_get_contents( $path, false, null, 0, self::MAX_NORMALIZED_BYTES + 1 );
605 if ( false === $content || strlen( $content ) > self::MAX_NORMALIZED_BYTES ) {
606 return null;
607 }
608 $extension = strtolower( pathinfo( (string) $relative, PATHINFO_EXTENSION ) );
609 // The last extension is not enough: x.php.svg runs as PHP where a handler
610 // matches any extension of the name, so it keeps its BOM.
611 if ( "\xEF\xBB\xBF" === substr( $content, 0, 3 ) && in_array( $extension, array( 'js', 'css', 'txt', 'md', 'html', 'htm', 'xml', 'svg', 'po', 'pot' ), true ) && ! self::is_executable_path( (string) $relative ) ) {
612 $content = substr( $content, 3 );
613 }
614 return hash( 'sha256', str_replace( array( "\r\n", "\r" ), "\n", $content ) );
615 }
616
617 /**
618 * Folder name of the plugin as installed (normally "vigilante").
619 *
620 * @return string
621 */
622 private static function plugin_folder() {
623 return dirname( VIGILANTE_PLUGIN_BASENAME );
624 }
625
626 /**
627 * Whether a path from the File Integrity ignore workflow points inside
628 * Vigilant's own folder, in either of the two conventions the scan uses
629 * (plugins/<folder>/... and the ABSPATH-relative one).
630 *
631 * Ignoring such a path silences the self-check for that file, so on a
632 * network it takes the same network rights as approving a change to
633 * wp-config.php or the root .htaccess: Vigilant's files are shared by
634 * every site, and the site that owns them reports for all of them.
635 *
636 * @param string $path Path as stored in vigilante_ignored_files.
637 * @return bool
638 */
639 public static function is_own_file_path( $path ) {
640 $path = ltrim( str_replace( '\\', '/', (string) $path ), '/' );
641 if ( '' === $path ) {
642 return false;
643 }
644 $plugin_prefix = 'plugins/' . self::plugin_folder() . '/';
645 $abspath_prefix = ltrim( str_replace( '\\', '/', str_replace( ABSPATH, '', VIGILANTE_PLUGIN_DIR ) ), '/' );
646 return 0 === strpos( $path, $plugin_prefix ) || ( '' !== $abspath_prefix && 0 === strpos( $path, $abspath_prefix ) );
647 }
648
649 // -------------------------------------------------------------------
650 // Anchors
651 // -------------------------------------------------------------------
652
653 /**
654 * Plugin root without trailing slash.
655 *
656 * @return string
657 */
658 private function get_plugin_root() {
659 return rtrim( VIGILANTE_PLUGIN_DIR, '/\\' );
660 }
661
662 /**
663 * Version of the code on disk (not the constant in memory: during an
664 * update the old code handles the hook while the new files are already
665 * on disk).
666 *
667 * @return string
668 */
669 private function get_disk_version() {
670 $main = $this->get_plugin_root() . '/' . basename( VIGILANTE_PLUGIN_BASENAME );
671 if ( is_readable( $main ) ) {
672 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading the plugin's own main file header; WP_Filesystem is not warranted here.
673 $head = (string) file_get_contents( $main, false, null, 0, 8192 );
674 if ( preg_match( '/^\s*\*\s*Version:\s*(\S+)\s*$/m', $head, $m ) ) {
675 return $m[1];
676 }
677 }
678 return defined( 'VIGILANTE_VERSION' ) ? VIGILANTE_VERSION : '';
679 }
680
681 /**
682 * Read and parse MANIFEST.sha256 (anchor A2).
683 *
684 * A line that is not "<64 hex> <path>", an unsafe path, a repeated path,
685 * more than MAX_MANIFEST_LINES lines or a file larger than
686 * MAX_MANIFEST_BYTES make the whole manifest invalid: it is never used
687 * partially.
688 *
689 * @return array|false|null Map relative path => sha256; false when the file
690 * exists but is not a valid manifest; null when absent.
691 */
692 private function read_manifest() {
693 $raw = $this->read_manifest_raw();
694 if ( null === $raw ) {
695 return null;
696 }
697 if ( false === $raw ) {
698 return false;
699 }
700 $manifest = array();
701 $lines = 0;
702 $length = strlen( $raw );
703 $offset = 0;
704 // Line by line over the string and not explode(): a manifest made of
705 // line breaks built an array of millions of empty strings and exhausted
706 // memory before a single line was judged, which ended the whole scan
707 // and the daily check with the last status still stored. Blank lines
708 // count towards the limit for the same reason.
709 while ( $offset < $length ) {
710 $end = strpos( $raw, "\n", $offset );
711 $end = false === $end ? $length : $end;
712 $line = substr( $raw, $offset, $end - $offset );
713 $offset = $end + 1;
714 $lines++;
715 if ( $lines > self::MAX_MANIFEST_LINES ) {
716 return false;
717 }
718 if ( '' === trim( $line ) ) {
719 continue;
720 }
721 if ( ! preg_match( '/^([0-9a-f]{64}) (.+)$/', $line, $m ) ) {
722 return false;
723 }
724 if ( ! self::is_safe_relative_path( $m[2] ) || isset( $manifest[ $m[2] ] ) ) {
725 return false;
726 }
727 if ( self::is_excluded_path( $m[2] ) ) {
728 continue;
729 }
730 $manifest[ $m[2] ] = $m[1];
731 }
732 return empty( $manifest ) ? false : $manifest;
733 }
734
735 /**
736 * Manifest content of this installation, line endings normalized.
737 *
738 * @return string|false|null See read_manifest_file().
739 */
740 private function read_manifest_raw() {
741 return self::read_manifest_file( $this->get_plugin_root() . '/' . self::MANIFEST_FILE );
742 }
743
744 /**
745 * Normalized fingerprint of the MANIFEST.sha256 in a folder, taken the same
746 * way as the one anchored in the database: the sha256 of the normalized
747 * content, 'invalid' for a file larger than MAX_MANIFEST_BYTES, null when
748 * there is none (absent, unreadable or a link).
749 *
750 * vigilante_mark_upgrader_wrote() takes it from the folder the WordPress
751 * updater has just written, and the check at the end of the request only
752 * trusts the updater when the manifest on disk is still that one.
753 *
754 * @param string $dir Folder, with or without a trailing slash.
755 * @return string|null
756 */
757 public static function manifest_fingerprint_of( $dir ) {
758 $raw = self::read_manifest_file( rtrim( (string) $dir, '/\\' ) . '/' . self::MANIFEST_FILE );
759 if ( false === $raw ) {
760 return 'invalid';
761 }
762 return is_string( $raw ) ? hash( 'sha256', $raw ) : null;
763 }
764
765 /**
766 * Read a manifest file, line endings normalized.
767 *
768 * @param string $path Absolute path of a MANIFEST.sha256.
769 * @return string|false|null Null when absent, unreadable or a link; false
770 * when larger than MAX_MANIFEST_BYTES, which is
771 * not read.
772 */
773 private static function read_manifest_file( $path ) {
774 if ( is_link( $path ) || ! is_readable( $path ) ) {
775 return null;
776 }
777 $size = filesize( $path );
778 if ( false === $size || $size > self::MAX_MANIFEST_BYTES ) {
779 return false;
780 }
781 // The read stops past the limit too: the size can change between the
782 // check above and the read.
783 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading the plugin's own manifest for verification; WP_Filesystem is not warranted here.
784 $raw = file_get_contents( $path, false, null, 0, self::MAX_MANIFEST_BYTES + 1 );
785 if ( false === $raw ) {
786 return null;
787 }
788 if ( strlen( $raw ) > self::MAX_MANIFEST_BYTES ) {
789 return false;
790 }
791 // A host or a deploy tool that rewrites line endings rewrites the
792 // manifest too. That gives an attacker nothing (an LF manifest reads
793 // the same), so the parser and the fingerprint read it normalized.
794 if ( "\xEF\xBB\xBF" === substr( $raw, 0, 3 ) ) {
795 $raw = substr( $raw, 3 );
796 }
797 return str_replace( array( "\r\n", "\r" ), "\n", $raw );
798 }
799
800 /**
801 * SHA-256 of the manifest (the value anchored in DB as A3), line endings
802 * normalized: for the manifest the generator writes, that is the SHA-256 of
803 * the file itself.
804 *
805 * @return string|null
806 */
807 private function get_manifest_fingerprint() {
808 $raw = $this->read_manifest_raw();
809 return is_string( $raw ) ? hash( 'sha256', $raw ) : null;
810 }
811
812 /**
813 * Fetch the wp.org SHA-256 checksums for a version (anchor A1).
814 *
815 * @param string $version Plugin version.
816 * @return array|string|null Map path => array of accepted sha256 strings,
817 * 'not_found' (cached 1 h) when wp.org has no
818 * checksums for the version, or null on
819 * transient network error (not cached).
820 */
821 public function get_wporg_sha256_checksums( $version ) {
822 if ( '' === $version ) {
823 return null;
824 }
825 $transient_key = self::CHECKSUMS_TRANSIENT_PREFIX . md5( $version );
826 $cached = get_transient( $transient_key );
827 if ( 'not_found' === $cached ) {
828 return 'not_found';
829 }
830 if ( is_array( $cached ) && ! empty( $cached ) ) {
831 return $cached;
832 }
833
834 $url = 'https://downloads.wordpress.org/plugin-checksums/vigilante/' . rawurlencode( $version ) . '.json';
835 $response = wp_remote_get(
836 $url,
837 array(
838 'timeout' => self::HTTP_TIMEOUT,
839 'limit_response_size' => 1048576,
840 )
841 );
842
843 if ( is_wp_error( $response ) ) {
844 return null;
845 }
846
847 $code = (int) wp_remote_retrieve_response_code( $response );
848 if ( 200 !== $code ) {
849 // 404 is normal right after a release (checksums not generated yet)
850 // and for untagged builds: cache it for an hour so admin pageloads in
851 // that window do not hammer the API. Any other answer (429, 5xx) is
852 // WordPress.org having a bad moment, and is asked again sooner.
853 set_transient( $transient_key, 'not_found', 404 === $code ? HOUR_IN_SECONDS : 5 * MINUTE_IN_SECONDS );
854 return 'not_found';
855 }
856
857 $data = json_decode( wp_remote_retrieve_body( $response ), true );
858 if ( ! is_array( $data ) || empty( $data['files'] ) || ! is_array( $data['files'] ) ) {
859 return null;
860 }
861
862 $checksums = array();
863 foreach ( $data['files'] as $file => $sums ) {
864 if ( count( $checksums ) >= self::MAX_MANIFEST_LINES ) {
865 break;
866 }
867 $file = str_replace( '\\', '/', (string) $file );
868 if ( ! is_array( $sums ) || ! isset( $sums['sha256'] ) || ! self::is_safe_relative_path( $file ) ) {
869 continue;
870 }
871 // wp.org publishes a string, or an array when the file changed on
872 // the same tag (a readme updated after the release).
873 $checksums[ $file ] = array_map( 'strval', (array) $sums['sha256'] );
874 }
875
876 if ( empty( $checksums ) ) {
877 return null;
878 }
879
880 set_transient( $transient_key, $checksums, DAY_IN_SECONDS );
881 return $checksums;
882 }
883
884 /**
885 * Drop the cached wp.org checksums for the disk and anchored versions.
886 * Called when an update (upgrader or FTP) is detected.
887 */
888 public function flush_self_transients() {
889 $state = $this->get_state();
890 delete_transient( self::CHECKSUMS_TRANSIENT_PREFIX . md5( $this->get_disk_version() ) );
891 if ( ! empty( $state['version'] ) ) {
892 delete_transient( self::CHECKSUMS_TRANSIENT_PREFIX . md5( $state['version'] ) );
893 }
894 }
895
896 // -------------------------------------------------------------------
897 // State
898 // -------------------------------------------------------------------
899
900 /**
901 * Stored state.
902 *
903 * @return array
904 */
905 public function get_state() {
906 $state = get_option( self::STATE_OPTION, array() );
907 return is_array( $state ) ? $state : array();
908 }
909
910 /**
911 * Persist state (autoload off: only read on demand).
912 *
913 * @param array $state State array.
914 */
915 private function save_state( $state ) {
916 if ( false === get_option( self::STATE_OPTION, false ) ) {
917 add_option( self::STATE_OPTION, $state, '', false );
918 } else {
919 update_option( self::STATE_OPTION, $state, false );
920 }
921 }
922
923 /**
924 * Capture the A3 fingerprint of the current manifest.
925 *
926 * @param string $via Capture origin: activation|upgrader|version_change|migration|first_run|state_resync.
927 * @return bool Whether a fingerprint was captured (valid manifest present).
928 */
929 public function capture_fingerprint( $via ) {
930 if ( ! is_array( $this->read_manifest() ) ) {
931 return false;
932 }
933 $fingerprint = $this->get_manifest_fingerprint();
934 if ( null === $fingerprint ) {
935 return false;
936 }
937 $state = $this->get_state();
938 $state['version'] = $this->get_disk_version();
939 $state['manifest_hash'] = $fingerprint;
940 $state['captured_at'] = time();
941 $state['captured_via'] = $via;
942 unset( $state['refused_version'] );
943 $this->save_state( $state );
944 return true;
945 }
946
947 // -------------------------------------------------------------------
948 // Main check
949 // -------------------------------------------------------------------
950
951 /**
952 * Run the self-integrity check.
953 *
954 * Decision matrix per file (with a valid manifest):
955 * - in manifest, missing on disk -> self_missing (critical)
956 * - symbolic link, or resolves outside -> self_symlink (critical)
957 * - disk != manifest and != wp.org -> self_modified (critical for code, warning for assets)
958 * - disk != manifest but == wp.org -> manifest_stale (critical)
959 * - disk == manifest but != wp.org -> distribution_mismatch (warning)
960 * - on disk, not in manifest -> self_extra (critical if PHP or a link)
961 * Anchor level:
962 * - manifest hash != A3, same version -> rebaseline if wp.org confirms the manifest;
963 * manifest_replaced (critical) otherwise
964 * - manifest hash != A3, version changed -> rebaseline if the upgrader did it or wp.org confirms;
965 * manifest_replaced if wp.org contradicts it;
966 * manifest_unverified (sticky warning) otherwise;
967 * a downgrade adds self_downgraded (warning)
968 * - anchored manifest gone or invalid -> manifest_missing / manifest_invalid
969 * (critical with the same version, warning otherwise)
970 * - never anchored, manifest absent/invalid -> verify against wp.org + manifest_missing / manifest_invalid (warning)
971 * - never anchored, nothing available -> no_anchors (info), no alarm
972 *
973 * User exclusions (excluded_paths / excluded_extensions /
974 * plugin_known_false_positives) do NOT apply here: respecting them would
975 * create a silencing vector, and the plugin folder has no variable
976 * content. The explicit per-file ignore list (vigilante_ignored_files)
977 * IS respected: it is a deliberate admin action.
978 *
979 * @param string $context scan|upgrader|version_change|watchdog|migration|activation.
980 * @return array {status, findings, files_checked, anchors, rebaselined, downgraded}
981 */
982 public function run_check( $context = 'scan' ) {
983 $result = array(
984 'status' => 'disabled',
985 'findings' => array(),
986 'files_checked' => 0,
987 'anchors' => array(
988 'manifest' => false,
989 'wporg' => false,
990 'fingerprint' => false,
991 ),
992 'rebaselined' => false,
993 // Same version, manifest adopted because wp.org confirms it (3.0.1).
994 'manifest_adopted' => false,
995 'downgraded' => false,
996 );
997
998 if ( ! $this->is_enabled() ) {
999 return $result;
1000 }
1001
1002 // An update in progress means half-copied files: skip instead of
1003 // raising a false critical. The next entry point re-checks. The
1004 // question is the one WordPress asks, not whether the file exists:
1005 // core treats a .maintenance older than ten minutes as over
1006 // (wp-includes/load.php, wp_is_maintenance_mode()), so a stale file
1007 // left in the root, by accident or on purpose, must not silence
1008 // the check while the site keeps serving pages.
1009 if ( function_exists( 'wp_is_maintenance_mode' ) ? wp_is_maintenance_mode() : file_exists( ABSPATH . '.maintenance' ) ) {
1010 $result['status'] = 'skipped';
1011 return $result;
1012 }
1013
1014 $disk_version = $this->get_disk_version();
1015 $manifest = $this->read_manifest();
1016 $manifest_ok = is_array( $manifest );
1017 $manifest_fp = $this->get_manifest_fingerprint();
1018 $wporg = $this->get_wporg_sha256_checksums( $disk_version );
1019 $wporg_ok = is_array( $wporg );
1020
1021 $state = $this->get_state();
1022 $previous_state = isset( $state['last_status'] ) ? $state['last_status'] : '';
1023 $stored_fp = isset( $state['manifest_hash'] ) ? (string) $state['manifest_hash'] : '';
1024 $stored_version = isset( $state['version'] ) ? (string) $state['version'] : '';
1025
1026 $findings = array();
1027 $refused = '';
1028
1029 // --- Anchor A3 evaluation -------------------------------------
1030 if ( $manifest_ok ) {
1031 if ( '' === $stored_fp ) {
1032 if ( $wporg_ok && ! $this->manifest_consistent_with_wporg( $manifest, $wporg ) ) {
1033 // The first manifest seen becomes the reference for good, so
1034 // it is not adopted when WordPress.org, the one reference out
1035 // of an attacker's reach, distributes something else for this
1036 // version: a manifest regenerated to list an added file would
1037 // otherwise read as verified forever.
1038 $findings[] = $this->finding(
1039 'manifest_replaced',
1040 self::MANIFEST_FILE,
1041 'critical',
1042 __( 'MANIFEST.sha256 does not describe what WordPress.org distributes for this version, so it was not adopted as the reference for this installation.', 'vigilante' )
1043 );
1044 $refused = (string) $disk_version;
1045 } elseif ( ! $wporg_ok && '' !== (string) ( $state['refused_version'] ?? '' ) && ( (string) $state['refused_version'] === (string) $disk_version || 'upgrader' !== $context ) ) {
1046 // WordPress.org already said the manifest on disk is not what
1047 // it distributes. Not answering now (down, blocked, a checksum
1048 // file not cached, or a version it does not publish) is no
1049 // reason to adopt one: that would anchor the copy the last
1050 // check refused, the same copy with its manifest touched,
1051 // since a blank line changes the fingerprint and not the files,
1052 // or the same copy with its Version: header raised to a number
1053 // WordPress.org will never have. A genuine reinstall waits for
1054 // WordPress.org to answer; only the WordPress updater writing
1055 // another version is trusted, as it is for an anchored copy.
1056 $findings[] = $this->finding(
1057 'manifest_replaced',
1058 self::MANIFEST_FILE,
1059 'critical',
1060 (string) $state['refused_version'] === (string) $disk_version
1061 ? __( 'MANIFEST.sha256 was not adopted as the reference for this installation: when WordPress.org was last asked, it distributed something else for this version, and it could not be asked now.', 'vigilante' )
1062 : ( '' === (string) $disk_version
1063 ? sprintf(
1064 /* translators: %s: version WordPress.org contradicted */
1065 __( 'MANIFEST.sha256 was not adopted as the reference for this installation: WordPress.org distributed something else for version %s, and the version now on disk could not be read.', 'vigilante' ),
1066 (string) $state['refused_version']
1067 )
1068 : sprintf(
1069 /* translators: 1: version WordPress.org contradicted, 2: version now on disk */
1070 __( 'MANIFEST.sha256 was not adopted as the reference for this installation: WordPress.org distributed something else for version %1$s, and version %2$s, now on disk, could not be confirmed with it.', 'vigilante' ),
1071 (string) $state['refused_version'],
1072 (string) $disk_version
1073 ) )
1074 );
1075 } else {
1076 // First run with the feature: capture quietly.
1077 $this->capture_fingerprint( 'activation' === $context || 'migration' === $context ? $context : 'first_run' );
1078 $stored_fp = (string) $manifest_fp;
1079 $stored_version = $disk_version;
1080 $this->log(
1081 'self_integrity_captured',
1082 __( 'Vigilant self-protection: manifest fingerprint captured', 'vigilante' ),
1083 array( 'version' => $disk_version ),
1084 'info'
1085 );
1086 }
1087 } elseif ( $manifest_fp !== $stored_fp ) {
1088 // Same version, different manifest, whatever the context: the
1089 // upgrader hook also fires for a plugin that was not written (a
1090 // bulk update that skipped it, an install that failed before
1091 // copying), so it is not proof that WordPress replaced the files.
1092 if ( $stored_version === $disk_version ) {
1093 if ( $wporg_ok && $this->manifest_consistent_with_wporg( $manifest, $wporg ) ) {
1094 // The manifest on disk IS the one WordPress.org
1095 // distributes for this version, so whatever was anchored
1096 // before was not. That happens to a site that had a test
1097 // zip of a version and then installed the published one:
1098 // until 3.0.1 it stayed critical for good, because the
1099 // repair reinstalls those same official files and lands
1100 // here again (B45 of the roadmap, reproduced on 18 Sep
1101 // 2026 with a seeded anchor).
1102 //
1103 // Adopting it is the same argument the version-change
1104 // branch below already makes: an attacker who edits
1105 // files and regenerates the manifest cannot also make
1106 // WordPress.org's published checksums agree with it,
1107 // and that agreement is what is being checked here,
1108 // file by file and in both directions.
1109 $result['rebaselined'] = true;
1110 $result['manifest_adopted'] = true;
1111 } else {
1112 // Same version, different manifest: swapped without an update.
1113 $findings[] = $this->finding(
1114 'manifest_replaced',
1115 self::MANIFEST_FILE,
1116 'critical',
1117 __( 'MANIFEST.sha256 was replaced without a plugin update. An attacker regenerating the manifest to hide file changes would look exactly like this.', 'vigilante' )
1118 );
1119 }
1120 } else {
1121 // Version changed: the WordPress updater, a manual/FTP
1122 // update, or an attacker who bumped the Version: header to
1123 // dodge the same-version manifest_replaced check above.
1124 $wporg_confirms = $wporg_ok && $this->manifest_consistent_with_wporg( $manifest, $wporg );
1125
1126 if ( $wporg_ok && ! $wporg_confirms ) {
1127 // wp.org HAS checksums for this version and the new
1128 // manifest does not match them: tampered distribution.
1129 $findings[] = $this->finding(
1130 'manifest_replaced',
1131 self::MANIFEST_FILE,
1132 'critical',
1133 __( 'The new MANIFEST.sha256 does not match what WordPress.org distributes for this version.', 'vigilante' )
1134 );
1135 } elseif ( 'upgrader' === $context || $wporg_confirms ) {
1136 // Trusted: either the WordPress updater performed the
1137 // update, or wp.org confirms the new manifest. Adopt the
1138 // new baseline.
1139 $result['rebaselined'] = true;
1140 } else {
1141 // Version changed AND the new manifest cannot be
1142 // confirmed against WordPress.org, outside the WordPress
1143 // updater. This is the signature of an attacker who
1144 // edited files, regenerated MANIFEST.sha256 and set the
1145 // Version: header to a value wp.org will never publish.
1146 // The previous fingerprint is kept (no rebaseline) and a
1147 // sticky warning is raised until wp.org can confirm the
1148 // version or the WordPress updater installs one. A manual
1149 // update in the first hours after a release lands here
1150 // too, and resolves on its own once wp.org publishes the
1151 // checksums.
1152 $findings[] = $this->finding(
1153 'manifest_unverified',
1154 self::MANIFEST_FILE,
1155 'warning',
1156 __( 'MANIFEST.sha256 changed after a Vigilant version change but could not be verified against WordPress.org. If you did not just update Vigilant, treat this as possible tampering.', 'vigilante' )
1157 );
1158 }
1159
1160 if ( '' !== $stored_version && version_compare( $disk_version, $stored_version, '<' ) ) {
1161 $result['downgraded'] = true;
1162 $findings[] = $this->finding(
1163 'self_downgraded',
1164 basename( VIGILANTE_PLUGIN_BASENAME ),
1165 'warning',
1166 sprintf(
1167 /* translators: 1: previous version, 2: current (older) version */
1168 __( 'Vigilant was downgraded from %1$s to %2$s. Older versions may contain publicly known vulnerabilities.', 'vigilante' ),
1169 $stored_version,
1170 $disk_version
1171 )
1172 );
1173 }
1174 }
1175 } elseif ( '' !== $stored_version && $stored_version !== $disk_version ) {
1176 // Fingerprint matches but the anchored version does not: the
1177 // state was restored from an old database backup while the
1178 // files (and their manifest) are current and still anchored.
1179 // Not an attack signal; resync quietly so the version change
1180 // detector converges instead of re-firing.
1181 $this->capture_fingerprint( 'state_resync' );
1182 $stored_version = $disk_version;
1183 }
1184 } elseif ( '' !== $stored_fp ) {
1185 // A manifest was anchored and now it is gone or unusable. With the
1186 // same version nothing legitimate removes it, and deleting it is
1187 // the cheapest way to hide file changes from this check.
1188 // Only the WordPress updater installing another version (a
1189 // downgrade to one without a manifest) makes it a warning; anything
1190 // else, an FTP upload with a new Version: header included, is how
1191 // file changes would be hidden, and is critical.
1192 $version_changed = '' !== $stored_version && $stored_version !== $disk_version;
1193 $severity = ( $version_changed && 'upgrader' === $context ) ? 'warning' : 'critical';
1194 if ( $version_changed && version_compare( $disk_version, $stored_version, '<' ) ) {
1195 $result['downgraded'] = true;
1196 $findings[] = $this->finding(
1197 'self_downgraded',
1198 basename( VIGILANTE_PLUGIN_BASENAME ),
1199 'warning',
1200 sprintf(
1201 /* translators: 1: previous version, 2: current (older) version */
1202 __( 'Vigilant was downgraded from %1$s to %2$s. Older versions may contain publicly known vulnerabilities.', 'vigilante' ),
1203 $stored_version,
1204 $disk_version
1205 )
1206 );
1207 }
1208 if ( false === $manifest ) {
1209 $findings[] = $this->finding(
1210 'manifest_invalid',
1211 self::MANIFEST_FILE,
1212 $severity,
1213 __( 'MANIFEST.sha256 was anchored for this installation and is no longer a valid manifest (an unexpected line, an unsafe path, too many lines or a file too large to be one), so it was not used.', 'vigilante' )
1214 );
1215 } else {
1216 $findings[] = $this->finding(
1217 'manifest_missing',
1218 self::MANIFEST_FILE,
1219 $severity,
1220 __( 'MANIFEST.sha256 was anchored for this installation and is now missing from the Vigilant folder. Deleting the manifest is how file changes would be hidden from this check.', 'vigilante' )
1221 );
1222 }
1223 }
1224
1225 // --- Per-file verification -------------------------------------
1226 $known = null;
1227 if ( $manifest_ok ) {
1228 $result['anchors']['manifest'] = true;
1229 $tree = $this->verify_tree( $manifest, $wporg_ok ? $wporg : null );
1230 $findings = array_merge( $findings, $tree['findings'] );
1231 $result['files_checked'] = $tree['files_checked'];
1232 $known = $manifest;
1233 } elseif ( $wporg_ok ) {
1234 if ( '' === $stored_fp ) {
1235 $findings[] = false === $manifest
1236 ? $this->finding(
1237 'manifest_invalid',
1238 self::MANIFEST_FILE,
1239 'warning',
1240 __( 'MANIFEST.sha256 is not a valid manifest. Verification degraded to the WordPress.org checksums only.', 'vigilante' )
1241 )
1242 : $this->finding(
1243 'manifest_missing',
1244 self::MANIFEST_FILE,
1245 'warning',
1246 __( 'MANIFEST.sha256 is missing from the Vigilant folder. Verification degraded to the WordPress.org checksums only.', 'vigilante' )
1247 );
1248 }
1249 $pseudo = array();
1250 foreach ( $wporg as $file => $hashes ) {
1251 if ( self::is_excluded_path( $file ) ) {
1252 continue;
1253 }
1254 $pseudo[ $file ] = $hashes; // Arrays accepted by verify_tree.
1255 }
1256 $tree = $this->verify_tree( $pseudo, null );
1257 $findings = array_merge( $findings, $tree['findings'] );
1258 $result['files_checked'] = $tree['files_checked'];
1259 $known = $pseudo;
1260 } elseif ( '' === $stored_fp ) {
1261 // Never anchored and no anchor available: degraded, informational,
1262 // never an alarm (an old install with wp.org unreachable must not
1263 // scream).
1264 $findings[] = $this->finding(
1265 'no_anchors',
1266 self::MANIFEST_FILE,
1267 'info',
1268 __( 'Neither MANIFEST.sha256 nor the WordPress.org checksums are available; the self-check cannot verify files right now.', 'vigilante' )
1269 );
1270 }
1271
1272 // --- Extra files ------------------------------------------------
1273 if ( null !== $known ) {
1274 $findings = array_merge( $findings, $this->detect_extra_files( $known ) );
1275 }
1276
1277 $result['anchors']['wporg'] = $wporg_ok;
1278 $result['anchors']['fingerprint'] = '' !== $stored_fp;
1279
1280 // --- Explicit admin ignore list ---------------------------------
1281 $findings = $this->filter_ignored_findings( $findings );
1282
1283 // --- Status aggregation ------------------------------------------
1284 $status = 'ok';
1285 $worst = $this->worst_severity( $findings );
1286 if ( 'critical' === $worst ) {
1287 $status = 'critical';
1288 } elseif ( 'warning' === $worst ) {
1289 $status = 'warning';
1290 } elseif ( ! $wporg_ok || ! $manifest_ok ) {
1291 $status = 'degraded';
1292 }
1293
1294 // --- Rebaseline A3 ------------------------------------------------
1295 if ( $result['rebaselined'] ) {
1296 $this->capture_fingerprint( 'upgrader' === $context ? 'upgrader' : 'version_change' );
1297 if ( 'upgrader' !== $context && $result['manifest_adopted'] ) {
1298 // Same version, another manifest, and wp.org confirms the one on
1299 // disk. Saying "version change" here would be false, and a log
1300 // line that consoles with something untrue is worse than no line.
1301 $this->log(
1302 'self_rebaselined',
1303 sprintf(
1304 /* translators: %s: plugin version */
1305 __( 'Vigilant self-protection: MANIFEST.sha256 of version %s adopted as the reference, confirmed by WordPress.org; baseline refreshed.', 'vigilante' ),
1306 $disk_version
1307 ),
1308 array(
1309 'previous_version' => $stored_version,
1310 'new_version' => $disk_version,
1311 ),
1312 'info'
1313 );
1314 } elseif ( 'upgrader' !== $context ) {
1315 $this->log(
1316 'self_rebaselined',
1317 $result['downgraded']
1318 ? sprintf(
1319 /* translators: %s: plugin version */
1320 __( 'Vigilant self-protection: downgrade to %s detected outside the WordPress updater and confirmed by WordPress.org; baseline refreshed.', 'vigilante' ),
1321 $disk_version
1322 )
1323 : sprintf(
1324 /* translators: %s: plugin version */
1325 __( 'Vigilant self-protection: version change detected outside the WordPress updater (manual/FTP update to %s); baseline refreshed.', 'vigilante' ),
1326 $disk_version
1327 ),
1328 array(
1329 'previous_version' => $stored_version,
1330 'new_version' => $disk_version,
1331 ),
1332 'info'
1333 );
1334 }
1335 }
1336
1337 // --- Persist ------------------------------------------------------
1338 $state = $this->get_state(); // Re-read: capture_fingerprint may have written.
1339 if ( '' !== $refused ) {
1340 $state['refused_version'] = $refused;
1341 }
1342 if ( empty( $state['manifest_hash'] ) ) {
1343 // Nothing anchored: remember the version seen, or the version
1344 // change detector would run the whole check on every call.
1345 $state['version'] = $disk_version;
1346 }
1347 $state['last_check'] = time();
1348 $state['last_context'] = $context;
1349 $state['last_status'] = $status;
1350 $state['last_findings'] = $this->cap_findings( $findings );
1351 $state['last_findings_total'] = count( $findings );
1352 $state['files_checked'] = $result['files_checked'];
1353 $state['anchors'] = $result['anchors'];
1354 if ( 'ok' === $status || 'degraded' === $status ) {
1355 $state['alerted_fingerprint'] = '';
1356 }
1357 $this->save_state( $state );
1358
1359 // A downgrade is reported by email wherever it is decided. The
1360 // updater and the version change paths send their own alert; the scan
1361 // and the daily check used to refresh the baseline in silence when
1362 // they got there first.
1363 if ( $result['downgraded'] && ! in_array( $context, array( 'upgrader', 'version_change' ), true ) ) {
1364 $this->maybe_send_self_alert( $findings, $context );
1365 }
1366
1367 // --- Log ------------------------------------------------------------
1368 if ( 'critical' === $worst || 'warning' === $worst ) {
1369 $this->log(
1370 'self_integrity_fail',
1371 sprintf(
1372 /* translators: %d: number of findings */
1373 _n(
1374 'Vigilant self-protection detected %d integrity finding in its own files',
1375 'Vigilant self-protection detected %d integrity findings in its own files',
1376 count( $findings ),
1377 'vigilante'
1378 ),
1379 count( $findings )
1380 ),
1381 array(
1382 'context' => $context,
1383 'findings' => $this->findings_summary( $findings ),
1384 ),
1385 $worst
1386 );
1387 } elseif ( in_array( $previous_state, array( 'critical', 'warning' ), true ) && in_array( $status, array( 'ok', 'degraded' ), true ) ) {
1388 $this->log(
1389 'self_integrity_restored',
1390 __( 'Vigilant self-protection: integrity restored, all files verify clean again', 'vigilante' ),
1391 array( 'context' => $context ),
1392 'info'
1393 );
1394 } elseif ( 'scan' !== $context && in_array( $status, array( 'ok', 'degraded' ), true ) ) {
1395 $this->log(
1396 'self_integrity_scan',
1397 sprintf(
1398 /* translators: 1: number of files, 2: check context */
1399 __( 'Vigilant self-check verified clean (%1$d files, context: %2$s)', 'vigilante' ),
1400 $result['files_checked'],
1401 $context
1402 ),
1403 array( 'context' => $context ),
1404 'info'
1405 );
1406 }
1407
1408 $result['status'] = $status;
1409 $result['findings'] = $findings;
1410 return $result;
1411 }
1412
1413 /**
1414 * Verify the plugin tree against a hash map.
1415 *
1416 * @param array $manifest Map path => sha256 string (or array of accepted strings).
1417 * @param array|null $wporg wp.org checksums for cross-decisions, or null.
1418 * @return array {findings, files_checked}
1419 */
1420 private function verify_tree( $manifest, $wporg ) {
1421 $root = $this->get_plugin_root();
1422 $real_root = realpath( $root );
1423 $findings = array();
1424 $checked = 0;
1425
1426 foreach ( $manifest as $relative => $expected ) {
1427 $expected_set = array_map( 'strval', (array) $expected );
1428 $path = $root . '/' . $relative;
1429 $checked++;
1430
1431 // A distributed file is never a link, and nothing listed may
1432 // resolve outside the plugin folder (a linked directory on the way).
1433 $real = realpath( $path );
1434 if ( is_link( $path ) || ( false !== $real && false !== $real_root && 0 !== strpos( $real, $real_root . DIRECTORY_SEPARATOR ) ) ) {
1435 $findings[] = $this->finding(
1436 'self_symlink',
1437 $relative,
1438 'critical',
1439 __( 'A Vigilant file is a symbolic link or resolves outside the plugin folder. Distributed files are never links.', 'vigilante' )
1440 );
1441 continue;
1442 }
1443
1444 // No exception for iCloud placeholders: WordPress does not run from
1445 // iCloud Drive, and skipping a missing file when a ".<name>.icloud"
1446 // sits next to it let anyone hide a deleted module that way.
1447 if ( ! is_file( $path ) ) {
1448 $findings[] = $this->finding(
1449 'self_missing',
1450 $relative,
1451 'critical',
1452 __( 'File listed in the Vigilant manifest is missing from disk. A deleted module silently stops protecting the site.', 'vigilante' )
1453 );
1454 continue;
1455 }
1456
1457 if ( ! is_readable( $path ) ) {
1458 $findings[] = $this->finding(
1459 'self_modified',
1460 $relative,
1461 'critical',
1462 __( 'A Vigilant file cannot be read, so it cannot be verified. Distributed files are readable; check its permissions.', 'vigilante' ),
1463 'unreadable'
1464 );
1465 continue;
1466 }
1467
1468 $actual = hash_file( 'sha256', $path );
1469 if ( ! in_array( $actual, $expected_set, true ) && self::is_text_path( $relative ) ) {
1470 // Some hosts and deploy tools rewrite text files (a UTF-8 BOM in
1471 // front, CRLF line endings) without changing a line of code. The
1472 // File Integrity scan has compared a normalized copy since 2.9.1,
1473 // and the self-check does the same, so an intact file is not
1474 // reported as tampering. A real change still fails both hashes.
1475 $normalized = self::normalized_hash( $path, $relative );
1476 if ( null !== $normalized && ( in_array( $normalized, $expected_set, true ) || ( null !== $wporg && isset( $wporg[ $relative ] ) && in_array( $normalized, $wporg[ $relative ], true ) ) ) ) {
1477 $actual = $normalized;
1478 }
1479 }
1480 if ( in_array( $actual, $expected_set, true ) ) {
1481 // Matches the manifest; cross-check the manifest itself against wp.org.
1482 if ( null !== $wporg && ! isset( $wporg[ $relative ] ) ) {
1483 // Listed in the local manifest, and not distributed by
1484 // WordPress.org at all for this version: a manifest
1485 // regenerated to include an added file looks like this.
1486 $findings[] = $this->finding(
1487 'distribution_mismatch',
1488 $relative,
1489 self::is_executable_path( $relative ) ? 'critical' : 'warning',
1490 __( 'File is listed in the local manifest, but WordPress.org does not distribute it for this version.', 'vigilante' )
1491 );
1492 } elseif ( null !== $wporg && empty( array_intersect( $expected_set, $wporg[ $relative ] ) ) ) {
1493 $findings[] = $this->finding(
1494 'distribution_mismatch',
1495 $relative,
1496 'warning',
1497 __( 'File matches the local manifest but not what WordPress.org distributes for this version. Expected for development builds; on a production install this can indicate a tampered distribution.', 'vigilante' )
1498 );
1499 }
1500 continue;
1501 }
1502
1503 if ( null !== $wporg && isset( $wporg[ $relative ] ) && in_array( $actual, $wporg[ $relative ], true ) ) {
1504 $findings[] = $this->finding(
1505 'manifest_stale',
1506 $relative,
1507 'critical',
1508 __( 'File is the original one from WordPress.org but does not match the local manifest: the manifest was replaced or the release was mis-generated.', 'vigilante' )
1509 );
1510 } else {
1511 // Code (PHP family or JavaScript) modified is a backdoor/XSS
1512 // risk: critical. A modified non-code asset (CSS, images, data)
1513 // is far more often an optimisation plugin or a host rewriting
1514 // it in place than an attack, so it is a warning (still logged
1515 // and emailed) with wording that says so.
1516 // The data files in includes/ decide what the plugin does
1517 // (scan-patterns.json is the malware signature base): emptying
1518 // one blinds a protection as surely as editing its PHP.
1519 $extension = strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) );
1520 $is_code = self::is_executable_path( $relative ) || 'js' === $extension || ( 'json' === $extension && 0 === strpos( $relative, 'includes/' ) );
1521 if ( $is_code ) {
1522 $findings[] = $this->finding(
1523 'self_modified',
1524 $relative,
1525 'critical',
1526 __( 'Vigilant code file modified: its content no longer matches the distributed version.', 'vigilante' )
1527 );
1528 } else {
1529 $findings[] = $this->finding(
1530 'self_modified',
1531 $relative,
1532 'warning',
1533 __( 'A Vigilant asset (not a code file) no longer matches the distributed version. This is often an optimisation plugin or host rewriting it in place; if you did not expect it, verify the file.', 'vigilante' )
1534 );
1535 }
1536 }
1537 }
1538
1539 return array(
1540 'findings' => $findings,
1541 'files_checked' => $checked,
1542 );
1543 }
1544
1545 /**
1546 * Files, links and folders on disk that are not part of the known set.
1547 *
1548 * Harmless extra files stop being listed after MAX_EXTRA_WARNINGS, but a
1549 * link, a file a web server can be told to run and a folder that cannot
1550 * be listed are always reported, wherever they sit in the walk. The first
1551 * version counted everything against one cap of 100, so a hundred
1552 * harmless files placed where the walk starts hid a PHP file behind them.
1553 *
1554 * A folder the check cannot list (no read or no search permission) hides
1555 * what is inside it, and a file in it can still be run by its name, so it
1556 * is critical whether the distribution has that folder or not. The walk
1557 * goes on past it instead of stopping, which is what the first version
1558 * did, reporting a clean folder.
1559 *
1560 * @param array $known Map path => hash (only keys are used).
1561 * @return array Findings.
1562 */
1563 private function detect_extra_files( $known ) {
1564 $root = $this->get_plugin_root();
1565 $findings = array();
1566 $warnings = 0;
1567 $entries = 0;
1568
1569 // Folders the distribution has, from the known paths.
1570 $known_dirs = array();
1571 foreach ( array_keys( $known ) as $known_path ) {
1572 $dir = dirname( (string) $known_path );
1573 while ( '.' !== $dir && '' !== $dir && ! isset( $known_dirs[ $dir ] ) ) {
1574 $known_dirs[ $dir ] = true;
1575 $dir = dirname( $dir );
1576 }
1577 }
1578
1579 try {
1580 $iterator = new RecursiveIteratorIterator(
1581 new RecursiveDirectoryIterator( $root, RecursiveDirectoryIterator::SKIP_DOTS ),
1582 RecursiveIteratorIterator::SELF_FIRST,
1583 RecursiveIteratorIterator::CATCH_GET_CHILD
1584 );
1585 foreach ( $iterator as $file ) {
1586 $entries++;
1587 if ( $entries > self::MAX_TREE_ENTRIES ) {
1588 $findings[] = $this->finding(
1589 'self_extra',
1590 '',
1591 'critical',
1592 __( 'The Vigilant folder holds far more files than the distribution, so the check stopped walking it. A distributed copy holds fewer than a hundred files.', 'vigilante' ),
1593 'walk'
1594 );
1595 break;
1596 }
1597
1598 $relative = str_replace( '\\', '/', substr( $file->getPathname(), strlen( $root ) + 1 ) );
1599 $is_link = $file->isLink();
1600
1601 if ( ! $is_link && $file->isDir() ) {
1602 // On Windows is_executable() of a folder is always false (PHP asks
1603 // whether it is a program), so only readability counts there.
1604 if ( ! $file->isReadable() || ( '\\' !== DIRECTORY_SEPARATOR && ! $file->isExecutable() ) ) {
1605 $findings[] = $this->finding(
1606 'self_extra',
1607 $relative . '/',
1608 'critical',
1609 isset( $known_dirs[ $relative ] )
1610 ? __( 'A Vigilant folder cannot be listed, so files added to it cannot be detected, and a web server can still run them by name. Distributed folders are readable: check its permissions.', 'vigilante' )
1611 : __( 'Folder inside Vigilant that is not part of the distribution and cannot be listed. Files hidden in it cannot be checked, and a web server can still run them by name.', 'vigilante' ),
1612 'dir'
1613 );
1614 }
1615 continue;
1616 }
1617 if ( ! $is_link && ! $file->isFile() ) {
1618 continue;
1619 }
1620 if ( isset( $known[ $relative ] ) ) {
1621 continue;
1622 }
1623 $executable = self::is_executable_path( $relative );
1624 // Excluded names are skipped, but never a link or an executable
1625 // file, whatever name it hides behind.
1626 if ( self::is_excluded_path( $relative ) && ! $is_link && ! $executable ) {
1627 continue;
1628 }
1629
1630 if ( $is_link ) {
1631 $findings[] = $this->finding(
1632 'self_extra',
1633 $relative,
1634 'critical',
1635 __( 'Symbolic link inside the Vigilant folder that is not part of the distribution.', 'vigilante' ),
1636 'link'
1637 );
1638 continue;
1639 }
1640 if ( $executable ) {
1641 $findings[] = $this->finding(
1642 'self_extra',
1643 $relative,
1644 'critical',
1645 __( 'Executable file inside the Vigilant folder that is not part of the distribution. Injected files here run with the plugin\'s own credibility.', 'vigilante' ),
1646 'exec'
1647 );
1648 continue;
1649 }
1650 if ( $warnings >= self::MAX_EXTRA_WARNINGS ) {
1651 continue;
1652 }
1653 $warnings++;
1654 $findings[] = $this->finding(
1655 'self_extra',
1656 $relative,
1657 'warning',
1658 __( 'File inside the Vigilant folder that is not part of the distribution.', 'vigilante' )
1659 );
1660 }
1661 } catch ( Exception $e ) {
1662 // The folder itself could not be opened: say so instead of
1663 // reporting a clean folder.
1664 unset( $e );
1665 $findings[] = $this->finding(
1666 'self_extra',
1667 '',
1668 'critical',
1669 __( 'The Vigilant folder could not be walked, so files added to it cannot be detected.', 'vigilante' ),
1670 'walk'
1671 );
1672 }
1673
1674 return $findings;
1675 }
1676
1677 /**
1678 * Whether the local manifest describes the same tree wp.org distributes.
1679 *
1680 * @param array $manifest Map path => sha256.
1681 * @param array $wporg Map path => array of sha256.
1682 * @return bool
1683 */
1684 private function manifest_consistent_with_wporg( $manifest, $wporg ) {
1685 foreach ( $manifest as $file => $hash ) {
1686 if ( ! isset( $wporg[ $file ] ) || ! in_array( (string) $hash, $wporg[ $file ], true ) ) {
1687 return false;
1688 }
1689 }
1690 foreach ( $wporg as $file => $hashes ) {
1691 if ( self::is_excluded_path( $file ) ) {
1692 continue;
1693 }
1694 if ( ! isset( $manifest[ $file ] ) ) {
1695 return false;
1696 }
1697 }
1698 return true;
1699 }
1700
1701 /**
1702 * Drop findings the admin explicitly ignored via the File Integrity
1703 * ignore workflow. Only per-file findings can be ignored; anchor-level
1704 * findings (manifest_replaced, manifest_missing, no_anchors...) always
1705 * surface.
1706 *
1707 * @param array $findings Findings.
1708 * @return array
1709 */
1710 private function filter_ignored_findings( $findings ) {
1711 $ignored = get_option( 'vigilante_ignored_files', array() );
1712 if ( empty( $ignored ) || ! is_array( $ignored ) ) {
1713 return $findings;
1714 }
1715 $per_file = array( 'self_modified', 'self_missing', 'self_extra', 'self_symlink', 'manifest_stale', 'distribution_mismatch' );
1716 $plugin_prefix = 'plugins/' . self::plugin_folder() . '/';
1717 $abspath_prefix = str_replace( ABSPATH, '', VIGILANTE_PLUGIN_DIR );
1718 return array_values(
1719 array_filter(
1720 $findings,
1721 function ( $finding ) use ( $ignored, $per_file, $plugin_prefix, $abspath_prefix ) {
1722 if ( ! in_array( $finding['code'], $per_file, true ) ) {
1723 return true;
1724 }
1725 // The walk findings (a folder that cannot be listed, the whole
1726 // folder that could not be walked, too many entries) are not
1727 // about one file: no entry of the list hides them.
1728 $file = (string) $finding['file'];
1729 if ( '' === $file || '/' === substr( $file, -1 ) ) {
1730 return true;
1731 }
1732 // Both path conventions used by the File Integrity UI.
1733 return ! in_array( $plugin_prefix . $finding['file'], $ignored, true ) && ! in_array( $abspath_prefix . $finding['file'], $ignored, true );
1734 }
1735 )
1736 );
1737 }
1738
1739 // -------------------------------------------------------------------
1740 // Update paths (#51)
1741 // -------------------------------------------------------------------
1742
1743 /**
1744 * Immediate verification after the WordPress upgrader updated Vigilant.
1745 * Called (already filtered to this plugin) from
1746 * vigilante_on_upgrader_process_complete() in vigilante.php. The OLD code
1747 * runs this handler while the NEW files are on disk, hence everything is
1748 * read from disk, never from in-memory constants.
1749 *
1750 * @param bool $written Whether WordPress wrote the plugin folder in this
1751 * request (see vigilante_mark_upgrader_wrote()).
1752 */
1753 public function handle_upgrader( $written = true ) {
1754 if ( ! $this->is_enabled() ) {
1755 return;
1756 }
1757
1758 $this->flush_self_transients();
1759 // Only when WordPress wrote the plugin folder in this request is the
1760 // updater proof of anything: a bulk update fires the hook for plugins it
1761 // skipped too. Otherwise it is a version change like any other.
1762 $result = $this->run_check( $written ? 'upgrader' : 'version_change' );
1763
1764 $worst = $this->worst_severity( $result['findings'] );
1765 $this->log(
1766 'self_verified_post_update',
1767 sprintf(
1768 /* translators: 1: plugin version, 2: verification status */
1769 __( 'Vigilant verified its own files right after updating to %1$s: %2$s', 'vigilante' ),
1770 $this->get_disk_version(),
1771 $result['status']
1772 ),
1773 array(
1774 'status' => $result['status'],
1775 'findings' => $this->findings_summary( $result['findings'] ),
1776 ),
1777 'critical' === $worst ? 'critical' : 'info'
1778 );
1779
1780 if ( 'critical' === $worst || $result['downgraded'] ) {
1781 $this->maybe_send_self_alert( $result['findings'], 'upgrader' );
1782 }
1783 }
1784
1785 /**
1786 * Version change detection from admin_init (priority 20).
1787 *
1788 * admin_init also fires on admin-post.php before anybody is identified,
1789 * and for logged-in users without any capability, so this only runs for
1790 * a user who can manage options: the check hashes the tree, writes the
1791 * state and can send an email, none of which an anonymous request may
1792 * trigger. The same guard as Vigilante_Admin::run_migrations().
1793 */
1794 public function maybe_detect_version_change() {
1795 if ( wp_doing_ajax() || wp_doing_cron() ) {
1796 return;
1797 }
1798 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
1799 return;
1800 }
1801 $this->detect_version_change();
1802 }
1803
1804 /**
1805 * Whether the WordPress updater has replaced Vigilant in this request and
1806 * the check of the new files is still to come
1807 *
1808 * vigilante_on_upgrader_process_complete() leaves that check for the end
1809 * of the request, once a failed update has had its previous copy restored.
1810 * Until then the code in memory is the old one and the files on disk are
1811 * the new ones, or half moved: taking that for a version change made
1812 * outside the updater, or checking the files from here, reported the
1813 * update WordPress had just made. It happens in one cron run, when a
1814 * background update is followed by the daily maintenance or by a task of
1815 * WordPress the watchdog hangs from.
1816 *
1817 * @since 3.0.5
1818 *
1819 * @return bool
1820 */
1821 private static function upgrade_check_pending() {
1822 return false !== has_action( 'shutdown', 'vigilante_verify_after_upgrade' );
1823 }
1824
1825 /**
1826 * Whether WordPress is in the middle of its background updates, in this
1827 * process or in another one
1828 *
1829 * WP_Automatic_Updater::run() holds a lock in the options table while it
1830 * works (WP_Upgrader::create_lock( 'auto_updater' )) and releases it at
1831 * the end; core itself takes it for abandoned after an hour. A mark dated
1832 * ahead of this server's clock counts only within five minutes, which is
1833 * a clock slightly out of step between two servers: with a whole hour of
1834 * margin each way, one write kept every check waiting for almost two
1835 * (the sixth blind round).
1836 *
1837 * The row is read from the table, not through get_option(). Releasing
1838 * the lock is a delete_option(), which notes the name as an option that
1839 * does not exist; the next lock is inserted with a query of its own, and
1840 * the update_option() that follows it writes the same value within the
1841 * same second, changes no row and leaves that note where it was. From
1842 * then on get_option() answers that there is no lock while there is one:
1843 * for the rest of the process on any site, and for every process on a
1844 * site with a persistent object cache (wp-admin/includes/class-wp-upgrader.php
1845 * :1065, :1087 and :1103 in 7.1.3).
1846 *
1847 * On a network the lock is in the options of the main site of the main
1848 * network, the only one where the updates run (class-wp-automatic-updater.php
1849 * :647), so it is looked for there from wherever this is asked.
1850 *
1851 * @since 3.0.5
1852 *
1853 * @return bool
1854 */
1855 private static function core_updates_running() {
1856 global $wpdb;
1857
1858 $switched = false;
1859 if ( is_multisite() && ! ( is_main_site() && is_main_network() ) ) {
1860 $main_site = (int) get_main_site_id( get_main_network_id() );
1861 if ( $main_site < 1 ) {
1862 return false;
1863 }
1864 switch_to_blog( $main_site );
1865 $switched = true;
1866 }
1867
1868 $lock = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'auto_updater.lock' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the cached answer is what cannot be trusted here: see the note above.
1869
1870 if ( $switched ) {
1871 restore_current_blog();
1872 }
1873
1874 if ( ! is_numeric( $lock ) ) {
1875 return false;
1876 }
1877 $age = time() - (int) $lock;
1878
1879 return $age < HOUR_IN_SECONDS && $age > -5 * MINUTE_IN_SECONDS;
1880 }
1881
1882 /**
1883 * FTP/manual update and downgrade detection: the anchored version in the
1884 * state differs from the running code. Also called from the daily
1885 * maintenance cron and from the scheduled tasks of WordPress. Throttled
1886 * while a change stays unresolved, and silent while the check that follows
1887 * an update by WordPress is pending (see upgrade_check_pending()) or
1888 * WordPress is running its background updates in another process (see
1889 * core_updates_running()).
1890 */
1891 public function detect_version_change() {
1892 if ( ! $this->is_enabled() || self::upgrade_check_pending() ) {
1893 return;
1894 }
1895
1896 $state = $this->get_state();
1897 $stored_version = isset( $state['version'] ) ? (string) $state['version'] : '';
1898 // The version on disk, not the constant in memory: run_check() stores
1899 // the one on disk, and with an opcode cache still serving the old files
1900 // the two differ, so comparing with the constant never converged.
1901 $running = $this->get_disk_version();
1902
1903 if ( '' !== $stored_version && $running === $stored_version ) {
1904 return;
1905 }
1906
1907 // A version that differs while WordPress holds the lock of its
1908 // background updates is, almost always, WordPress replacing Vigilant
1909 // in another process: the daily maintenance that falls due in a second
1910 // cron process, or an administrator opening the dashboard meanwhile,
1911 // found the new files and took them for a change made outside the
1912 // updater (the blind review of 3.0.5). The process that updates checks
1913 // them when it ends. Asked only once the version has changed and the
1914 // throttle allows an attempt, so a page of the dashboard does not read
1915 // the table for it while a change stays unresolved, and before the
1916 // throttle is set, so waiting does not use up the next attempt.
1917 if ( get_transient( self::VERSION_CHECK_TRANSIENT ) ) {
1918 return;
1919 }
1920 if ( self::core_updates_running() ) {
1921 return;
1922 }
1923 set_transient( self::VERSION_CHECK_TRANSIENT, 1, self::VERSION_CHECK_THROTTLE );
1924
1925 if ( '' === $stored_version ) {
1926 // Feature just arrived (fresh install handled by the activator,
1927 // updates by the 3.0.0 migration); this is the belt-and-braces
1928 // path. run_check() captures quietly.
1929 $this->run_check( 'version_change' );
1930 return;
1931 }
1932
1933 // Flush the cached checksums once per detected version, not on every
1934 // throttled retry, so a version wp.org has not published yet is asked
1935 // for again only when its not_found cache expires.
1936 if ( ! isset( $state['pending_version'] ) || $running !== $state['pending_version'] ) {
1937 $this->flush_self_transients();
1938 $state['pending_version'] = $running;
1939 $this->save_state( $state );
1940 }
1941
1942 $result = $this->run_check( 'version_change' );
1943
1944 // Alert on a confirmed tamper (critical), a downgrade, or an
1945 // unverifiable manifest change (the forged-version bypass signature),
1946 // so a version change that cannot be vouched for never passes silently.
1947 $has_unverified = false;
1948 foreach ( $result['findings'] as $finding ) {
1949 if ( 'manifest_unverified' === $finding['code'] ) {
1950 $has_unverified = true;
1951 break;
1952 }
1953 }
1954 if ( 'critical' === $this->worst_severity( $result['findings'] ) || $result['downgraded'] || $has_unverified ) {
1955 $this->maybe_send_self_alert( $result['findings'], 'version_change' );
1956 }
1957 }
1958
1959 // -------------------------------------------------------------------
1960 // Cron watchdog (#52)
1961 // -------------------------------------------------------------------
1962
1963 /**
1964 * Throttled watchdog entry point (admin_init priority 30), for a user who
1965 * can manage options only (see maybe_detect_version_change()). The second
1966 * entry point is daily_maintenance() in vigilante.php, for sites where
1967 * nobody visits wp-admin.
1968 */
1969 public function maybe_run_watchdog() {
1970 if ( wp_doing_ajax() || wp_doing_cron() ) {
1971 return;
1972 }
1973 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
1974 return;
1975 }
1976 if ( ! $this->is_enabled() || get_transient( self::WATCHDOG_TRANSIENT ) ) {
1977 return;
1978 }
1979 set_transient( self::WATCHDOG_TRANSIENT, 1, 6 * HOUR_IN_SECONDS );
1980 $this->run_watchdog();
1981 }
1982
1983 /**
1984 * The same self-protection the daily maintenance runs, from the scheduled
1985 * tasks of WordPress itself
1986 *
1987 * The two other entry points depend on what the watchdog watches: an
1988 * administrator opening wp-admin, and the daily maintenance of Vigilant,
1989 * which is one of the tasks that get removed. On a site where something
1990 * removed every task, the daily maintenance and the weekly Security Check
1991 * stayed missing until an administrator came by, and what the watchdog
1992 * counted was those visits: coming by every ten days, it never reached
1993 * the count that makes it critical (the blind review of 3.0.5). The update
1994 * checks of WordPress run twice a day whether or not the tasks of Vigilant
1995 * are there, so a missing task is put back within hours and the count
1996 * follows what happens to the tasks, not who visits.
1997 *
1998 * Cron only: these actions have the same names as functions that
1999 * WordPress calls directly on other requests, and this must not run on a
2000 * page load. And only on the site that owns the files of the installation:
2001 * every site of a network runs its own cron, the files are the same for
2002 * all of them, and the main site already checks them.
2003 *
2004 * @since 3.0.5
2005 */
2006 public function run_watchdog_from_core_cron() {
2007 if ( $this->core_cron_pass_done || ! wp_doing_cron() ) {
2008 return;
2009 }
2010 $this->core_cron_pass_done = true;
2011 if ( ! Vigilante_Settings::owns_shared_files() ) {
2012 return;
2013 }
2014 // upgrade_check_pending() is only known inside the process that runs
2015 // the update. A second cron process that starts while the first one is
2016 // still inside the background updates of WordPress finds Vigilant
2017 // already replaced and nothing pending, and this pass would take the
2018 // new version for a change made outside the updater. It comes back
2019 // with the next task of WordPress, within hours. The two checks it
2020 // would run ask the same thing by themselves, for their other callers;
2021 // asked here too, this entry does not schedule anything meanwhile.
2022 if ( self::core_updates_running() ) {
2023 return;
2024 }
2025
2026 // Same order as Vigilante_Main::daily_maintenance(), and for the same
2027 // reasons: the version change first, and switched off is said aloud.
2028 if ( ! $this->is_enabled() ) {
2029 $this->audit_off_state();
2030 return;
2031 }
2032 $this->detect_version_change();
2033 $this->run_watchdog();
2034 }
2035
2036 /**
2037 * Verify that Vigilant's own cron events are still scheduled and restore
2038 * any that were unscheduled (a malicious wp_clear_scheduled_hook, or an
2039 * overzealous cleanup plugin, silences the plugin forever otherwise).
2040 *
2041 * A hook is only reported once the watchdog has seen it scheduled: the
2042 * first pass on a site, and a hook whose setting was just switched on,
2043 * schedule what is missing quietly. After that, a first disappearance is
2044 * restored with a line in the log. The SAME hook missing again within 30
2045 * days is a warning on screen, with no email and no effect on the scores,
2046 * and it closes on its own after a week with nothing missing: WordPress
2047 * drops a recurring task by itself when it cannot save its next run, so
2048 * that much proves nothing. Three times inside one week, or four inside
2049 * the 30 days, means something is actively clearing it: critical and a
2050 * standalone alert, one a week at most, which says tasks and not files. Until 3.0.4 the second time was already critical,
2051 * the texts said that the files of the plugin had been modified, and the
2052 * finding stayed for 30 days. A hook whose setting is off is forgotten, so
2053 * switching it back on is not reported either.
2054 *
2055 * On a network it only watches the site that owns the installation's
2056 * shared files: the activation schedules these events once, so on the
2057 * other sites "not scheduled" does not mean "removed". The events of the
2058 * other sites are left for the multisite release.
2059 *
2060 * Runs only while the plugin is active, so legitimate deactivation (which
2061 * clears these events in Vigilante_Deactivator) makes no noise, and no
2062 * new cron of its own is ever registered. The password-expiry reminder
2063 * (vigilante_password_expiry_reminder) is deliberately NOT in this table:
2064 * User Security schedules it according to its own toggle.
2065 *
2066 * As a fallback for sites with the File Integrity module (and therefore
2067 * the scan) disabled, a daily self-check also runs from here.
2068 */
2069 public function run_watchdog() {
2070 $fi = $this->settings ? $this->settings->get_section( 'file_integrity' ) : array();
2071 $modules = $this->settings ? $this->settings->get_section( 'modules' ) : array();
2072 $analyzer = $this->settings ? $this->settings->get_section( 'security_analyzer' ) : array();
2073
2074 if ( Vigilante_Settings::owns_shared_files() ) {
2075 // hook => array( schedule, first-run offset, required? ).
2076 // The weekly analyzer scan is required unless it was switched off
2077 // explicitly: its own toggle unschedules it on purpose, and an
2078 // unset value means on (Vigilante_Security_Analyzer reads it the same way).
2079 // vigilante_hourly_checks is not here since 3.0.5: its callback
2080 // does nothing, so it missing takes nothing away, and being hourly
2081 // it was the task WordPress dropped most often.
2082 $expected = array(
2083 'vigilante_daily_maintenance' => array( 'daily', 0, true ),
2084 'vigilante_analyzer_weekly_scan' => array( 'weekly', DAY_IN_SECONDS, ! isset( $analyzer['weekly_scan_enabled'] ) || ! empty( $analyzer['weekly_scan_enabled'] ) ),
2085 'vigilante_plugin_status_check' => array( 'daily', HOUR_IN_SECONDS, ! empty( $fi['check_closed_plugins'] ) ),
2086 'vigilante_file_integrity_scan' => array(
2087 isset( $fi['scan_frequency'] ) ? $fi['scan_frequency'] : 'daily',
2088 0,
2089 ! empty( $modules['file_integrity'] ) && ! empty( $fi['auto_scan'] ),
2090 ),
2091 );
2092
2093 // A recurrence nobody registered (an imported or edited setting)
2094 // cannot be scheduled: use daily instead of failing on every pass.
2095 $schedules = wp_get_schedules();
2096 foreach ( $expected as $hook => $spec ) {
2097 if ( ! isset( $schedules[ $spec[0] ] ) ) {
2098 $expected[ $hook ][0] = 'daily';
2099 }
2100 }
2101
2102 $state = $this->get_state();
2103 $watchdog = ( isset( $state['watchdog'] ) && is_array( $state['watchdog'] ) ) ? $state['watchdog'] : array();
2104 $first_pass = ! isset( $state['watchdog_seen'] ) || ! is_array( $state['watchdog_seen'] );
2105 $seen = $first_pass ? array() : $state['watchdog_seen'];
2106 $baselined = array();
2107 $restored = array();
2108 $repeated = array();
2109 $now = time();
2110
2111 // What is no longer watched is forgotten, with whatever it had
2112 // counted: the hourly task of the versions up to 3.0.4.
2113 $seen = array_intersect_key( $seen, $expected );
2114 $watchdog = array_intersect_key( $watchdog, $expected );
2115
2116 foreach ( $expected as $hook => $spec ) {
2117 list( $schedule, $offset, $required ) = $spec;
2118
2119 if ( ! $required ) {
2120 unset( $seen[ $hook ], $watchdog[ $hook ] );
2121 continue;
2122 }
2123 if ( wp_next_scheduled( $hook ) ) {
2124 $seen[ $hook ] = true;
2125 continue;
2126 }
2127
2128 $scheduled = wp_schedule_event( $now + $offset, $schedule, $hook );
2129 if ( false === $scheduled || is_wp_error( $scheduled ) ) {
2130 // Not scheduled, so it cannot be counted as restored: the
2131 // next pass tries again without reporting a removal.
2132 continue;
2133 }
2134
2135 if ( empty( $seen[ $hook ] ) ) {
2136 $seen[ $hook ] = true;
2137 $baselined[] = $hook;
2138 continue;
2139 }
2140
2141 // Every restoration of the last 30 days, with its date: how
2142 // close together they are is what tells a task WordPress lost
2143 // from one that something keeps removing.
2144 $previous = ( isset( $watchdog[ $hook ] ) && is_array( $watchdog[ $hook ] ) ) ? $watchdog[ $hook ] : array();
2145 $times = self::watchdog_times( $previous, $now, true );
2146 $times[] = $now;
2147 $times = array_slice( $times, -10 );
2148
2149 $watchdog[ $hook ] = array(
2150 'count' => count( $times ),
2151 'last' => $now,
2152 'times' => $times,
2153 );
2154 $restored[] = $hook;
2155 if ( 'critical' === self::watchdog_severity( $watchdog[ $hook ], $now ) ) {
2156 $repeated[] = $hook;
2157 }
2158 }
2159
2160 $state['watchdog'] = $watchdog;
2161 $state['watchdog_seen'] = $seen;
2162 $this->save_state( $state );
2163
2164 if ( ! empty( $baselined ) ) {
2165 $this->log(
2166 'cron_scheduled',
2167 sprintf(
2168 /* translators: %s: comma-separated list of cron hooks */
2169 __( 'Vigilant watchdog scheduled cron events that were not scheduled yet: %s', 'vigilante' ),
2170 implode( ', ', $baselined )
2171 ),
2172 array( 'scheduled' => $baselined ),
2173 'info'
2174 );
2175 }
2176
2177 if ( ! empty( $restored ) ) {
2178 $this->log(
2179 'cron_restored',
2180 sprintf(
2181 /* translators: %s: comma-separated list of cron hooks */
2182 __( 'Vigilant watchdog re-scheduled missing cron events: %s', 'vigilante' ),
2183 implode( ', ', $restored )
2184 ),
2185 array(
2186 'restored' => $restored,
2187 'recurrence' => $watchdog,
2188 ),
2189 empty( $repeated ) ? 'warning' : 'critical'
2190 );
2191 }
2192
2193 // What is critical right now, and not only what this pass put
2194 // back: an email that could not be sent is tried again by the next
2195 // pass for as long as the finding lasts, instead of waiting for
2196 // one more removal that may never come.
2197 $findings = array();
2198 foreach ( self::watchdog_findings( $state ) as $finding ) {
2199 if ( 'critical' !== $finding['severity'] ) {
2200 continue;
2201 }
2202 $findings[] = $this->finding(
2203 'cron_cleared_repeatedly',
2204 $finding['file'],
2205 'critical',
2206 sprintf(
2207 /* translators: %s: cron hook name */
2208 __( 'The scheduled task "%s" keeps going missing, and Vigilant scheduled it again each time. Something on this site keeps removing it.', 'vigilante' ),
2209 $finding['file']
2210 )
2211 );
2212 }
2213
2214 if ( ! empty( $findings ) ) {
2215 /*
2216 * One email a week at most while it goes on, kept apart from
2217 * the fingerprint of the alerts about files. That fingerprint
2218 * is cleared by every clean check of the files, so with it
2219 * alone this email went out again every day; and with a
2220 * standing warning about the files it was never cleared, so a
2221 * second episode of the same tasks went out to nobody.
2222 */
2223 $state = $this->get_state();
2224 $alerted = isset( $state['watchdog_alerted'] ) ? (int) $state['watchdog_alerted'] : 0;
2225 if ( $alerted > $now + DAY_IN_SECONDS ) {
2226 // Written with the clock ahead: it would hold the email back until then.
2227 $alerted = 0;
2228 }
2229 if ( ( $now - $alerted ) >= self::WATCHDOG_RECENT_WINDOW && $this->maybe_send_self_alert( $findings, 'watchdog', false ) ) {
2230 $state = $this->get_state();
2231 $state['watchdog_alerted'] = $now;
2232 $this->save_state( $state );
2233 }
2234 }
2235 }
2236
2237 // Fallback file self-check whenever nothing checked the files in the
2238 // last day: the File Integrity module switched off, scheduled scans
2239 // switched off, or a weekly schedule. Asking only for the module left a
2240 // site with scheduled scans switched off with no periodic check at all.
2241 $state = $this->get_state();
2242 $last_check = isset( $state['last_check'] ) ? (int) $state['last_check'] : 0;
2243 if ( ! self::upgrade_check_pending() && time() - $last_check > DAY_IN_SECONDS && ! self::core_updates_running() ) {
2244 $result = $this->run_check( 'watchdog' );
2245 if ( 'critical' === $this->worst_severity( $result['findings'] ) ) {
2246 $this->maybe_send_self_alert( $result['findings'], 'watchdog' );
2247 }
2248 }
2249 }
2250
2251 // -------------------------------------------------------------------
2252 // Alerts and integration surface
2253 // -------------------------------------------------------------------
2254
2255 /**
2256 * Standalone alert for the upgrader, version change and watchdog paths.
2257 *
2258 * Deliberately NOT gated by the File Integrity instant_alert toggle
2259 * (unlike Vigilante_Plugin_Status): a tamper of the guardian itself is
2260 * the product's maximum alarm. There is no setting that silences it; only
2261 * the filter of is_on(), which SECURITY.md documents. Findings raised by a
2262 * File Integrity scan come through here too: switching off the report about
2263 * changed files must not switch off the alarm about the plugin itself.
2264 *
2265 * Sent only from the site that owns the installation's shared files: the
2266 * plugin files are the same for every site and every network of an
2267 * installation, so one email. Deduped by a fingerprint of the finding
2268 * set; cleared when back to ok.
2269 *
2270 * @param array $findings Findings to report.
2271 * @param string $context Alert context (upgrader|version_change|watchdog).
2272 * @param bool $by_set Whether the set of findings is what keeps it from
2273 * being sent twice. False for the watchdog, which
2274 * keeps its own count of time (see run_watchdog()).
2275 * @return bool Whether it was sent.
2276 */
2277 public function maybe_send_self_alert( $findings, $context, $by_set = true ) {
2278 if ( empty( $findings ) ) {
2279 return false;
2280 }
2281
2282 if ( ! Vigilante_Settings::owns_shared_files() ) {
2283 return false;
2284 }
2285 $summary = $this->findings_summary( $findings );
2286 $fingerprint = md5( wp_json_encode( $summary ) );
2287 $state = $this->get_state();
2288 if ( $by_set && isset( $state['alerted_fingerprint'] ) && $fingerprint === $state['alerted_fingerprint'] ) {
2289 return false;
2290 }
2291
2292 if ( ! class_exists( 'Vigilante_Email_Template' ) ) {
2293 require_once VIGILANTE_INCLUDES_DIR . 'class-email-template.php';
2294 }
2295 $recipients = Vigilante_Email_Template::get_admin_recipients();
2296 // On a network the notification recipients are settings of the main
2297 // site, which its administrator can change without network rights, so
2298 // the network administration email is added to every one of these
2299 // alerts. One send, one fingerprint: the set of findings is what is
2300 // deduped, for the site addresses and for the network one alike.
2301 if ( is_multisite() ) {
2302 $network_email = get_site_option( 'admin_email' );
2303 if ( is_email( $network_email ) ) {
2304 $recipients[] = $network_email;
2305 }
2306 $recipients = array_values( array_unique( $recipients ) );
2307 }
2308 if ( empty( $recipients ) ) {
2309 return false;
2310 }
2311
2312 $worst = $this->worst_severity( $findings );
2313 $site_name = get_bloginfo( 'name' );
2314 // Tasks that keep being removed, the check switched off and its hooks
2315 // removed are not files that changed, and the email said files for all
2316 // of them until 3.0.4.
2317 $kind = ( 'critical' === $worst ) ? self::findings_kind( $findings, 'critical' ) : '';
2318 // Switched off by code after the last check had found changed files:
2319 // the files travel in the alert, but they were already announced when
2320 // they were found. What is new, and what a scan from File Integrity
2321 // will not look at, is that the check has been switched off.
2322 $off_after = ( 'files' === $kind ) && self::has_code( $findings, array( 'self_disabled' ) );
2323 if ( $off_after ) {
2324 $kind = 'off';
2325 }
2326 $tasks = 'tasks' === $kind;
2327 $check_off = in_array( $kind, array( 'off', 'hooks' ), true );
2328
2329 if ( $check_off ) {
2330 if ( 'off' === $kind ) {
2331 /* translators: %s: Site name */
2332 $subject_text = __( '[%s] CRITICAL: Vigilant self-protection is switched off by code', 'vigilante' );
2333 $lead = __( 'Vigilant self-protection is switched off by code on this site.', 'vigilante' );
2334 } else {
2335 /* translators: %s: Site name */
2336 $subject_text = __( '[%s] CRITICAL: the hooks of Vigilant self-protection were removed', 'vigilante' );
2337 $lead = __( 'Something removed the hooks that run the self-check.', 'vigilante' );
2338 }
2339 $subject = sprintf( $subject_text, $site_name );
2340 $title = __( 'Vigilant self-protection alert', 'vigilante' );
2341 $intro = $lead . ' ' . ( $off_after
2342 ? __( 'Before that, the last check that ran had found changed files in Vigilant, and they are listed below. Unless you did both things yourself, treat this as a compromise: changing the security plugin and then switching off the check that reports it is what an attacker does.', 'vigilante' )
2343 : __( 'This alert is about the check itself, not about changed files. While this goes on, the files of Vigilant are not being checked as they should. If you did not do this yourself, treat it as a possible compromise.', 'vigilante' ) );
2344 } elseif ( $tasks ) {
2345 $subject = sprintf(
2346 /* translators: %s: Site name */
2347 __( '[%s] CRITICAL: Vigilant scheduled tasks keep being removed', 'vigilante' ),
2348 $site_name
2349 );
2350 $title = __( 'Vigilant self-protection alert', 'vigilante' );
2351 $intro = __( 'Scheduled tasks of Vigilant keep going missing, and Vigilant scheduled them again each time. This alert is about scheduled tasks, not about changed files. Something on this site keeps removing them: a cleanup plugin or a scheduled job is the usual cause, and if you find neither, treat it as a possible compromise.', 'vigilante' );
2352 } elseif ( 'critical' === $worst ) {
2353 $subject = sprintf(
2354 /* translators: %s: Site name */
2355 __( '[%s] CRITICAL: Vigilant plugin files were modified', 'vigilante' ),
2356 $site_name
2357 );
2358 $title = __( 'Vigilant self-protection alert', 'vigilante' );
2359 $intro = __( 'Vigilant verified its own files and they no longer match the distributed version. Treat this as a possible compromise of the site: an attacker tampering with the security plugin is trying to blind it.', 'vigilante' );
2360 } else {
2361 $subject = sprintf(
2362 /* translators: %s: Site name */
2363 __( '[%s] Vigilant self-protection warning', 'vigilante' ),
2364 $site_name
2365 );
2366 $title = __( 'Vigilant self-protection warning', 'vigilante' );
2367 $intro = __( 'Vigilant detected a change in its own installation that deserves your attention.', 'vigilante' );
2368 }
2369
2370 $body = Vigilante_Email_Template::p( $intro );
2371 $body .= self::build_self_email_section( $findings );
2372 if ( $tasks ) {
2373 $how = __( 'How to see them by yourself: a plugin such as WP Crontrol lists the scheduled events of your site, and the ones of Vigilant start with vigilante_.', 'vigilante' );
2374 } elseif ( $check_off ) {
2375 // A scan does not check these files while the check is switched off.
2376 $how = __( 'How to check the files of Vigilant by yourself meanwhile: over SSH, in the folder of the plugin, with "php bin/verify-manifest.php" or "sha256sum -c MANIFEST.sha256". Full instructions live in the SECURITY.md file distributed with the plugin.', 'vigilante' );
2377 } else {
2378 $how = __( 'How to verify by yourself: run a scan from File Integrity, or check the folder over SSH with "php bin/verify-manifest.php" or "sha256sum -c MANIFEST.sha256". Full instructions live in the SECURITY.md file distributed with the plugin.', 'vigilante' );
2379 }
2380 $body .= Vigilante_Email_Template::p( $how );
2381 $body .= Vigilante_Email_Template::button(
2382 admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-last-scan' ),
2383 __( 'Review in Vigilant', 'vigilante' )
2384 );
2385 $body .= Vigilante_Email_Template::small(
2386 sprintf(
2387 /* translators: %s: where the check ran (upgrader, version_change or watchdog) */
2388 __( 'This alert is part of Vigilant self-protection and does not depend on the instant alert setting. Context: %s', 'vigilante' ),
2389 $context
2390 )
2391 );
2392
2393 $delivered = Vigilante_Email_Template::send( $recipients, $subject, $title, $body, true );
2394 if ( ! $delivered && 1 === count( (array) $recipients ) ) {
2395 // One address, so false means that address did not get it: nothing
2396 // is marked, and the next check takes the alert to it again. With
2397 // several recipients false is not proof of anything (PHPMailer
2398 // returns it when one of them is refused and the others were sent),
2399 // so those count as sent, or the alert would be repeated to
2400 // everybody who did get it.
2401 return false;
2402 }
2403
2404 if ( $by_set ) {
2405 $state['alerted_fingerprint'] = $fingerprint;
2406 $this->save_state( $state );
2407 }
2408
2409 return true;
2410 }
2411
2412 /**
2413 * Red email sub-section for self findings, mirroring the closed-plugins
2414 * section style in the File Integrity digest.
2415 *
2416 * @param array $findings Findings.
2417 * @return string HTML.
2418 */
2419 public static function build_self_email_section( $findings ) {
2420 if ( empty( $findings ) ) {
2421 return '';
2422 }
2423
2424 $html = '<div style="background:#fef1f1;border-left:4px solid #d63638;padding:12px 16px;margin:16px 0;">';
2425 $html .= '<h2 style="margin:0 0 8px;font-size:16px;color:#d63638;">' . esc_html__( 'Vigilant self-protection', 'vigilante' ) . '</h2>';
2426 // What the rows are, so the line above them and the first column say it.
2427 $kind = self::findings_kind( $findings );
2428 if ( 'files' === $kind && self::has_code( $findings, array( 'self_disabled', 'self_hooks_removed' ) ) ) {
2429 // Both things: a row about the check itself is not a file of Vigilant.
2430 $about = __( 'Findings about the self-check of Vigilant and about its own files.', 'vigilante' );
2431 $column = __( 'File', 'vigilante' );
2432 } elseif ( 'tasks' === $kind ) {
2433 $about = __( 'Findings about the scheduled tasks of Vigilant, not about its files.', 'vigilante' );
2434 $column = __( 'Task', 'vigilante' );
2435 } elseif ( 'off' === $kind || 'hooks' === $kind ) {
2436 $about = __( 'Findings about the self-check of Vigilant itself, not about changed files.', 'vigilante' );
2437 // Switched off by code lists the files that hook the filter.
2438 $column = ( 'hooks' === $kind ) ? __( 'Hook', 'vigilante' ) : __( 'File', 'vigilante' );
2439 } else {
2440 $about = __( 'Findings about Vigilant\'s own files. If you did not modify them yourself, treat this as a possible compromise.', 'vigilante' );
2441 $column = __( 'File', 'vigilante' );
2442 }
2443 $html .= '<p style="margin:0 0 10px;color:#3c434a;">' . esc_html( $about ) . '</p>';
2444 $html .= '<table style="border-collapse:collapse;width:100%;font-size:13px;">';
2445 $html .= '<tr><th style="text-align:left;padding:4px 8px;border-bottom:1px solid #dcdcde;">' . esc_html( $column ) . '</th>'
2446 . '<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #dcdcde;">' . esc_html__( 'Finding', 'vigilante' ) . '</th>'
2447 . '<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #dcdcde;">' . esc_html__( 'Severity', 'vigilante' ) . '</th></tr>';
2448
2449 foreach ( array_slice( $findings, 0, self::MAX_STORED_FINDINGS ) as $finding ) {
2450 $html .= '<tr>'
2451 . '<td style="padding:4px 8px;border-bottom:1px solid #f0f0f1;"><code>' . esc_html( $finding['file'] ) . '</code></td>'
2452 . '<td style="padding:4px 8px;border-bottom:1px solid #f0f0f1;">' . esc_html( self::finding_label( $finding['code'] ) ) . '</td>'
2453 . '<td style="padding:4px 8px;border-bottom:1px solid #f0f0f1;">' . esc_html( $finding['severity'] ) . '</td>'
2454 . '</tr>';
2455 }
2456
2457 $html .= '</table>';
2458
2459 /*
2460 * The same catalogue the screens use: an email that reports tampering
2461 * in the security plugin and leaves the reader with no next step is
2462 * half a tool. One block per kind of finding, worst first, up to three.
2463 */
2464 $seen = array();
2465 $shown = 0;
2466 foreach ( $findings as $finding ) {
2467 $guidance = Vigilante_Self_Integrity_Guidance::for_finding( $finding );
2468 if ( isset( $seen[ $guidance['key'] ] ) || $shown >= 3 ) {
2469 continue;
2470 }
2471 $seen[ $guidance['key'] ] = true;
2472 ++$shown;
2473
2474 $html .= '<p style="margin:12px 0 0;color:#3c434a;"><strong>' . esc_html( $guidance['title'] ) . '.</strong> '
2475 . esc_html( $guidance['meaning'] ) . '</p>';
2476 if ( ! empty( $guidance['steps'] ) ) {
2477 $html .= '<p style="margin:6px 0 0;color:#3c434a;"><strong>' . esc_html__( 'What to do:', 'vigilante' ) . '</strong></p>'
2478 . '<ol style="margin:4px 0 0;padding-left:20px;color:#3c434a;">';
2479 foreach ( $guidance['steps'] as $step ) {
2480 $html .= '<li style="margin-bottom:4px;">' . esc_html( $step ) . '</li>';
2481 }
2482 $html .= '</ol>';
2483 }
2484 }
2485
2486 $html .= '<p style="margin:12px 0 0;"><a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ) . '">'
2487 . esc_html__( 'Open File Integrity in your site', 'vigilante' ) . '</a></p>';
2488 $html .= '</div>';
2489 return $html;
2490 }
2491
2492 /**
2493 * State to show on a screen of this site.
2494 *
2495 * The files belong to the whole network but the state option is per site,
2496 * so the most recent of the two checks that can see those files (this site
2497 * and the main site of the main network) is the one that describes them.
2498 * Without this, the main site can report tampering while a subsite keeps
2499 * showing the clean result it wrote days ago.
2500 *
2501 * @return array
2502 */
2503 public static function display_state() {
2504 $state = get_option( self::STATE_OPTION, array() );
2505 $state = is_array( $state ) ? $state : array();
2506 if ( ! is_multisite() ) {
2507 return $state;
2508 }
2509 $main_id = (int) get_main_site_id( get_main_network_id() );
2510 if ( (int) get_current_blog_id() === $main_id ) {
2511 return $state;
2512 }
2513 $main = get_blog_option( $main_id, self::STATE_OPTION, array() );
2514 $main = is_array( $main ) ? $main : array();
2515 $here = isset( $state['last_check'] ) ? (int) $state['last_check'] : 0;
2516 $there = isset( $main['last_check'] ) ? (int) $main['last_check'] : 0;
2517 return ( $there > $here ) ? $main : $state;
2518 }
2519
2520 /**
2521 * The scheduled tasks the watchdog looks after
2522 *
2523 * The same names as the table of run_watchdog(). A task left out of here
2524 * is not reported even if an older version left a count for it in the
2525 * state: the hourly task up to 3.0.4.
2526 *
2527 * @since 3.0.5
2528 *
2529 * @return string[]
2530 */
2531 public static function watched_hooks() {
2532 return array(
2533 'vigilante_daily_maintenance',
2534 'vigilante_analyzer_weekly_scan',
2535 'vigilante_plugin_status_check',
2536 'vigilante_file_integrity_scan',
2537 );
2538 }
2539
2540 /**
2541 * When a task was restored, over the last 30 days
2542 *
2543 * An entry written up to 3.0.4 holds a count and the date of the last one
2544 * only. It is read as that date twice at most: enough for the warning it
2545 * had earned, never for the critical one, whose dates are not known.
2546 *
2547 * @since 3.0.5
2548 *
2549 * That reading is for the screens. To add a restoration to such an entry
2550 * only the one date it really holds is kept: carrying the made-up second
2551 * one forward turned "once three weeks ago and once three days ago" into
2552 * three this week on the next loss, which is critical and an email.
2553 *
2554 * @param array $entry One entry of the watchdog state.
2555 * @param int|null $now Current time.
2556 * @param bool $to_keep Whether the list is going to be stored with a new date added.
2557 * @return int[] Timestamps, oldest first.
2558 */
2559 public static function watchdog_times( $entry, $now = null, $to_keep = false ) {
2560 $now = null === $now ? time() : (int) $now;
2561 $times = array();
2562
2563 if ( ! is_array( $entry ) ) {
2564 return $times;
2565 }
2566
2567 if ( isset( $entry['times'] ) && is_array( $entry['times'] ) ) {
2568 foreach ( $entry['times'] as $time ) {
2569 // Whole numbers only: the state is an option, and anything
2570 // else in it is not a date.
2571 if ( is_int( $time ) || ( is_string( $time ) && ctype_digit( $time ) ) ) {
2572 $times[] = (int) $time;
2573 }
2574 }
2575 } elseif ( isset( $entry['last'] ) && ( is_int( $entry['last'] ) || ( is_string( $entry['last'] ) && ctype_digit( $entry['last'] ) ) ) && (int) $entry['last'] > 0 ) {
2576 $count = ( isset( $entry['count'] ) && is_numeric( $entry['count'] ) ) ? (int) $entry['count'] : 0;
2577 $times = array_fill( 0, $to_keep ? 1 : max( 1, min( 2, $count ) ), (int) $entry['last'] );
2578 }
2579
2580 // A date more than a day ahead was written with the clock wrong: kept,
2581 // it would count as recent for as long as it stayed ahead.
2582 $recent = array();
2583 foreach ( $times as $time ) {
2584 if ( $time > 0 && $time <= $now + DAY_IN_SECONDS && ( $now - $time ) < self::WATCHDOG_REPEAT_WINDOW ) {
2585 $recent[] = $time;
2586 }
2587 }
2588 sort( $recent, SORT_NUMERIC );
2589
2590 return $recent;
2591 }
2592
2593 /**
2594 * How serious the restorations of one task are, right now
2595 *
2596 * Nothing while the last one is more than a week old, so the finding
2597 * closes on its own once the task stays put. A warning from the second one
2598 * in 30 days. Critical from the third inside one week, or the fourth
2599 * inside the 30 days.
2600 *
2601 * @since 3.0.5
2602 *
2603 * @param array $entry One entry of the watchdog state.
2604 * @param int|null $now Current time.
2605 * @return string '', warning or critical.
2606 */
2607 public static function watchdog_severity( $entry, $now = null ) {
2608 $now = null === $now ? time() : (int) $now;
2609 $times = self::watchdog_times( $entry, $now );
2610 $week = 0;
2611
2612 foreach ( $times as $time ) {
2613 if ( ( $now - $time ) < self::WATCHDOG_RECENT_WINDOW ) {
2614 ++$week;
2615 }
2616 }
2617
2618 if ( $week < 1 || count( $times ) < 2 ) {
2619 return '';
2620 }
2621
2622 return ( $week >= self::WATCHDOG_ACTIVE_COUNT || count( $times ) >= self::WATCHDOG_MONTH_COUNT ) ? 'critical' : 'warning';
2623 }
2624
2625 /**
2626 * Scheduled tasks the watchdog had to schedule again more than once. They
2627 * are logged when they happen, but they are not part of last_findings, so
2628 * the screens read them from the watchdog state.
2629 *
2630 * @param array $state State.
2631 * @return array Findings, in the same shape as last_findings.
2632 */
2633 public static function watchdog_findings( $state ) {
2634 $findings = array();
2635 $watchdog = ( isset( $state['watchdog'] ) && is_array( $state['watchdog'] ) ) ? $state['watchdog'] : array();
2636 $watched = self::watched_hooks();
2637
2638 foreach ( $watchdog as $hook => $entry ) {
2639 if ( ! is_array( $entry ) || ! in_array( (string) $hook, $watched, true ) ) {
2640 continue;
2641 }
2642 $severity = self::watchdog_severity( $entry );
2643 if ( '' === $severity ) {
2644 continue;
2645 }
2646 $findings[] = array(
2647 'code' => 'cron_cleared_repeatedly',
2648 'file' => (string) $hook,
2649 'severity' => $severity,
2650 'message' => '',
2651 );
2652 }
2653 return $findings;
2654 }
2655
2656 /**
2657 * What a set of findings is about
2658 *
2659 * Not every critical finding of the self-check is a changed file: the
2660 * check can be switched off through its filter, its hooks can be removed,
2661 * and scheduled tasks can keep going missing. Until 3.0.4 the email, the
2662 * box, the notices and the note on the scores worded all of them as
2663 * modified files. When several kinds come together the strongest one names
2664 * the alarm: a changed file first, then the check switched off, then its
2665 * hooks, then the tasks.
2666 *
2667 * @since 3.0.5
2668 *
2669 * @param array $findings Findings.
2670 * @param string $severity Only look at the findings of this severity, or '' for all of them.
2671 * @return string files|off|hooks|tasks, or '' when there is none to look at.
2672 */
2673 public static function findings_kind( $findings, $severity = '' ) {
2674 $rank = array(
2675 'tasks' => 1,
2676 'hooks' => 2,
2677 'off' => 3,
2678 'files' => 4,
2679 );
2680 $codes = array(
2681 'cron_cleared_repeatedly' => 'tasks',
2682 'self_hooks_removed' => 'hooks',
2683 'self_disabled' => 'off',
2684 );
2685 $kind = '';
2686
2687 foreach ( (array) $findings as $finding ) {
2688 if ( ! is_array( $finding ) ) {
2689 continue;
2690 }
2691 if ( '' !== $severity && $severity !== ( isset( $finding['severity'] ) ? (string) $finding['severity'] : '' ) ) {
2692 continue;
2693 }
2694 // Whatever is not one of the three is a finding about the files.
2695 $code = isset( $finding['code'] ) ? (string) $finding['code'] : '';
2696 $this_kind = isset( $codes[ $code ] ) ? $codes[ $code ] : 'files';
2697 if ( '' === $kind || $rank[ $this_kind ] > $rank[ $kind ] ) {
2698 $kind = $this_kind;
2699 }
2700 }
2701
2702 return $kind;
2703 }
2704
2705 /**
2706 * Whether a set of findings holds any of these codes
2707 *
2708 * @since 3.0.5
2709 *
2710 * @param array $findings Findings.
2711 * @param array $codes Finding codes.
2712 * @return bool
2713 */
2714 private static function has_code( $findings, $codes ) {
2715 foreach ( (array) $findings as $finding ) {
2716 if ( is_array( $finding ) && isset( $finding['code'] ) && in_array( $finding['code'], $codes, true ) ) {
2717 return true;
2718 }
2719 }
2720 return false;
2721 }
2722
2723 /**
2724 * Whether a set of findings is only about scheduled tasks
2725 *
2726 * @since 3.0.5
2727 *
2728 * @param array $findings Findings.
2729 * @param string $severity Only look at the findings of this severity, or '' for all of them.
2730 * @return bool True when there is at least one and all of them are about tasks.
2731 */
2732 public static function findings_are_about_tasks( $findings, $severity = '' ) {
2733 return 'tasks' === self::findings_kind( $findings, $severity );
2734 }
2735
2736 /**
2737 * What the alarm of this site is about, for the texts that announce it
2738 *
2739 * @since 3.0.5
2740 *
2741 * @param array $state State.
2742 * @param bool $enabled Whether the self-check runs (see is_on()).
2743 * @return string files|off|hooks|tasks, or '' when there is no alarm.
2744 */
2745 public static function alarm_kind( $state, $enabled = true ) {
2746 // The last check of the files ended in critical: files, whatever else
2747 // there is. That includes the check being switched off afterwards,
2748 // which is what somebody who changed a file would do next: nothing
2749 // writes the state from then on, so it still holds what was found.
2750 $files = 'critical' === ( isset( $state['last_status'] ) ? (string) $state['last_status'] : '' );
2751 if ( ! $enabled ) {
2752 return $files ? 'files' : 'off';
2753 }
2754 if ( 'critical' !== self::tone( $state, true ) ) {
2755 return '';
2756 }
2757 if ( $files ) {
2758 return 'files';
2759 }
2760
2761 $kind = self::findings_kind( self::state_findings( $state, true ), 'critical' );
2762 if ( '' === $kind ) {
2763 // Critical with no critical finding to read: the removed hooks are
2764 // what tone() looks at before anything else.
2765 return empty( $state['hooks_removed']['methods'] ) ? 'files' : 'hooks';
2766 }
2767
2768 return $kind;
2769 }
2770
2771 /**
2772 * Whether the critical state of this site is about scheduled tasks and not about files
2773 *
2774 * @since 3.0.5
2775 *
2776 * @param array $state State.
2777 * @param bool $enabled Whether the self-check runs (see is_on()).
2778 * @return bool
2779 */
2780 public static function alarm_is_about_tasks( $state, $enabled = true ) {
2781 return 'tasks' === self::alarm_kind( $state, $enabled );
2782 }
2783
2784 /**
2785 * Findings of a state (stored plus watchdog), or none when the check is off.
2786 *
2787 * @param array $state State.
2788 * @param bool $enabled Whether the self-check runs (see is_on()).
2789 * @return array
2790 */
2791 public static function state_findings( $state, $enabled = true ) {
2792 $findings = array();
2793
2794 /*
2795 * Being switched off is a finding, and a critical one. It takes code on
2796 * the site to do it, which is either an administrator who decided it or
2797 * something that does not want to be checked; either way the screens say
2798 * so, with the files that hook the filter, instead of a grey line.
2799 */
2800 if ( ! $enabled ) {
2801 $ficheros = self::disabled_by();
2802 if ( empty( $ficheros ) ) {
2803 $ficheros = array( '' );
2804 }
2805 foreach ( $ficheros as $fichero ) {
2806 $findings[] = array(
2807 'code' => 'self_disabled',
2808 'file' => $fichero,
2809 'severity' => 'critical',
2810 'message' => '',
2811 );
2812 }
2813 return $findings;
2814 }
2815
2816 // Hooks that somebody removed in a request: same tier.
2817 if ( ! empty( $state['hooks_removed']['methods'] ) && is_array( $state['hooks_removed']['methods'] ) ) {
2818 foreach ( $state['hooks_removed']['methods'] as $metodo ) {
2819 $findings[] = array(
2820 'code' => 'self_hooks_removed',
2821 'file' => (string) $metodo,
2822 'severity' => 'critical',
2823 'message' => '',
2824 );
2825 }
2826 }
2827
2828 $stored = ( isset( $state['last_findings'] ) && is_array( $state['last_findings'] ) ) ? $state['last_findings'] : array();
2829
2830 // A result older than STALE_AFTER is not a result: say so instead of
2831 // repeating the green of the last check that did run.
2832 if ( ! empty( $state['last_check'] ) && ( time() - (int) $state['last_check'] ) > self::STALE_AFTER ) {
2833 $findings[] = array(
2834 'code' => 'self_stale',
2835 'file' => '',
2836 'severity' => 'warning',
2837 'message' => '',
2838 );
2839 }
2840
2841 return array_merge( $findings, $stored, self::watchdog_findings( $state ) );
2842 }
2843
2844 /**
2845 * Tone of the state: what colours the File Integrity box, the Security
2846 * Check result, the menu counter and the notices. One function, so the four
2847 * never disagree.
2848 *
2849 * @param array $state State.
2850 * @param bool $enabled Whether the self-check runs (see is_on()).
2851 * @return string critical|warning|info|ok|off|none
2852 */
2853 public static function tone( $state, $enabled = true ) {
2854 return self::tone_of( $state, $enabled, false );
2855 }
2856
2857 /**
2858 * The tone that counts for the two scores
2859 *
2860 * The same as tone(), except that tasks restored more than once, while
2861 * that is only a warning, do not count: Vigilant has already scheduled
2862 * them again and its files are as verified as they were, which is what the
2863 * points of this check are for. The box and the notice still show it.
2864 *
2865 * @since 3.0.5
2866 *
2867 * @param array $state State.
2868 * @param bool $enabled Whether the self-check runs (see is_on()).
2869 * @return string critical|warning|info|ok|off|none
2870 */
2871 public static function score_tone( $state, $enabled = true ) {
2872 return self::tone_of( $state, $enabled, true );
2873 }
2874
2875 /**
2876 * @param array $state State.
2877 * @param bool $enabled Whether the self-check runs.
2878 * @param bool $for_score Leave out the warnings about restored tasks.
2879 * @return string
2880 */
2881 private static function tone_of( $state, $enabled, $for_score ) {
2882 if ( ! $enabled ) {
2883 // Switched off by code is an alarm, not a neutral state: see
2884 // state_findings().
2885 return 'off';
2886 }
2887 if ( ! empty( $state['hooks_removed']['methods'] ) ) {
2888 return 'critical';
2889 }
2890 if ( empty( $state['last_check'] ) ) {
2891 return 'none';
2892 }
2893 $findings = self::state_findings( $state, true );
2894 $worst = 'none';
2895 foreach ( $findings as $finding ) {
2896 $severity = isset( $finding['severity'] ) ? (string) $finding['severity'] : '';
2897 if ( $for_score && 'warning' === $severity && 'cron_cleared_repeatedly' === ( isset( $finding['code'] ) ? (string) $finding['code'] : '' ) ) {
2898 continue;
2899 }
2900 if ( 'critical' === $severity ) {
2901 $worst = 'critical';
2902 break;
2903 }
2904 if ( 'warning' === $severity ) {
2905 $worst = 'warning';
2906 }
2907 }
2908 $status = isset( $state['last_status'] ) ? (string) $state['last_status'] : '';
2909 if ( 'critical' === $worst || 'critical' === $status ) {
2910 return 'critical';
2911 }
2912 $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
2913 if ( 'warning' === $worst || 'warning' === $status || $files < 1 ) {
2914 return 'warning';
2915 }
2916 $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
2917 return ( count( array_filter( $anchors ) ) >= 3 ) ? 'ok' : 'info';
2918 }
2919
2920 /**
2921 * Says out loud, once a day, that the self-check is switched off.
2922 *
2923 * Runs from the daily maintenance, which is not gated by is_on(): with the
2924 * check off nothing else would write a line, and an installation that
2925 * stopped checking itself in silence is exactly what this release is about.
2926 */
2927 public function audit_off_state() {
2928 if ( self::is_on() ) {
2929 return;
2930 }
2931 $state = $this->get_state();
2932 $alerted = isset( $state['off_alerted'] ) ? (int) $state['off_alerted'] : 0;
2933 if ( ( time() - $alerted ) < self::OFF_ALARM_WINDOW ) {
2934 return;
2935 }
2936 $state['off_alerted'] = time();
2937 $this->save_state( $state );
2938
2939 $ficheros = self::disabled_by();
2940 $findings = array(
2941 $this->finding(
2942 'self_disabled',
2943 $ficheros ? implode( ', ', $ficheros ) : '',
2944 'critical',
2945 __( 'Vigilant self-protection is switched off by code on this site.', 'vigilante' )
2946 ),
2947 );
2948 // Switched off after the last check had found changed files: the alert
2949 // carries them too, so it is not read as only switched off.
2950 if ( 'critical' === ( isset( $state['last_status'] ) ? (string) $state['last_status'] : '' ) && isset( $state['last_findings'] ) && is_array( $state['last_findings'] ) ) {
2951 foreach ( $state['last_findings'] as $stored ) {
2952 if ( is_array( $stored ) && isset( $stored['code'], $stored['file'], $stored['severity'] ) && 'critical' === $stored['severity'] ) {
2953 $findings[] = $stored;
2954 }
2955 }
2956 }
2957 $this->maybe_send_self_alert( $findings, 'disabled' );
2958 $this->log(
2959 'self_disabled',
2960 $ficheros
2961 ? sprintf(
2962 /* translators: %s: comma-separated list of files */
2963 __( 'Vigilant self-protection is switched off by code in: %s', 'vigilante' ),
2964 implode( ', ', $ficheros )
2965 )
2966 : __( 'Vigilant self-protection is switched off by code on this site', 'vigilante' ),
2967 array( 'files' => $ficheros ),
2968 'critical'
2969 );
2970 }
2971
2972 /**
2973 * Human label for a finding code.
2974 *
2975 * @param string $code Finding code.
2976 * @return string
2977 */
2978 public static function finding_label( $code ) {
2979 $labels = array(
2980 'self_modified' => __( 'Modified file', 'vigilante' ),
2981 'self_missing' => __( 'Missing file', 'vigilante' ),
2982 'self_extra' => __( 'Extra file', 'vigilante' ),
2983 'self_symlink' => __( 'Symbolic link', 'vigilante' ),
2984 'manifest_stale' => __( 'Manifest replaced (file is original)', 'vigilante' ),
2985 'manifest_replaced' => __( 'Manifest replaced', 'vigilante' ),
2986 'distribution_mismatch' => __( 'Differs from wp.org distribution', 'vigilante' ),
2987 'self_downgraded' => __( 'Version downgraded', 'vigilante' ),
2988 'manifest_missing' => __( 'Manifest missing', 'vigilante' ),
2989 'manifest_invalid' => __( 'Manifest not valid', 'vigilante' ),
2990 'manifest_unverified' => __( 'Manifest changed, unverifiable against wp.org', 'vigilante' ),
2991 'no_anchors' => __( 'No verification anchors available', 'vigilante' ),
2992 'cron_cleared_repeatedly' => __( 'Scheduled task missing more than once', 'vigilante' ),
2993 'self_disabled' => __( 'Self-protection switched off by code', 'vigilante' ),
2994 'self_hooks_removed' => __( 'Self-protection hooks removed', 'vigilante' ),
2995 'self_stale' => __( 'Not checked recently', 'vigilante' ),
2996 );
2997 return isset( $labels[ $code ] ) ? $labels[ $code ] : $code;
2998 }
2999
3000 // -------------------------------------------------------------------
3001 // Internals
3002 // -------------------------------------------------------------------
3003
3004 /**
3005 * Build a finding entry.
3006 *
3007 * @param string $code Finding code (matrix row).
3008 * @param string $file Path relative to the plugin root (or hook name).
3009 * @param string $severity info|warning|critical.
3010 * @param string $message Human explanation.
3011 * @param string $variant Optional variant of the same code, so the guidance
3012 * catalogue can tell apart cases that share it (an
3013 * unreadable file from a modified one, an injected
3014 * executable from a stray log file). Stored only when
3015 * set, and never part of the dedupe fingerprint.
3016 * @return array
3017 */
3018 private function finding( $code, $file, $severity, $message, $variant = '' ) {
3019 $finding = array(
3020 'code' => $code,
3021 'file' => $file,
3022 'severity' => $severity,
3023 'message' => $message,
3024 );
3025 if ( '' !== $variant ) {
3026 $finding['variant'] = $variant;
3027 }
3028 return $finding;
3029 }
3030
3031 /**
3032 * Worst severity present in a finding set.
3033 *
3034 * @param array $findings Findings.
3035 * @return string critical|warning|info|none
3036 */
3037 private function worst_severity( $findings ) {
3038 $worst = 'none';
3039 foreach ( $findings as $finding ) {
3040 if ( 'critical' === $finding['severity'] ) {
3041 return 'critical';
3042 }
3043 if ( 'warning' === $finding['severity'] ) {
3044 $worst = 'warning';
3045 } elseif ( 'info' === $finding['severity'] && 'none' === $worst ) {
3046 $worst = 'info';
3047 }
3048 }
3049 return $worst;
3050 }
3051
3052 /**
3053 * Findings sorted worst first and cut to MAX_STORED_FINDINGS, so a
3054 * manifest crafted with thousands of entries cannot bloat the state.
3055 *
3056 * @param array $findings Findings.
3057 * @return array
3058 */
3059 private function cap_findings( $findings ) {
3060 if ( count( $findings ) <= self::MAX_STORED_FINDINGS ) {
3061 return $findings;
3062 }
3063 $rank = array(
3064 'critical' => 0,
3065 'warning' => 1,
3066 'info' => 2,
3067 );
3068 $keyed = array();
3069 foreach ( array_values( $findings ) as $index => $finding ) {
3070 $keyed[] = array( isset( $rank[ $finding['severity'] ] ) ? $rank[ $finding['severity'] ] : 3, $index, $finding );
3071 }
3072 usort(
3073 $keyed,
3074 function ( $a, $b ) {
3075 return ( $a[0] === $b[0] ) ? $a[1] - $b[1] : $a[0] - $b[0];
3076 }
3077 );
3078 return array_map(
3079 function ( $entry ) {
3080 return $entry[2];
3081 },
3082 array_slice( $keyed, 0, self::MAX_STORED_FINDINGS )
3083 );
3084 }
3085
3086 /**
3087 * Compact, stable summary of findings for logs and dedupe fingerprints.
3088 *
3089 * @param array $findings Findings.
3090 * @return array
3091 */
3092 private function findings_summary( $findings ) {
3093 $summary = array();
3094 foreach ( $this->cap_findings( $findings ) as $finding ) {
3095 $summary[] = $finding['code'] . ':' . $finding['file'];
3096 }
3097 sort( $summary, SORT_STRING );
3098 return $summary;
3099 }
3100
3101 /**
3102 * Log through the Activity Log when available.
3103 *
3104 * @param string $action Event action.
3105 * @param string $message Message.
3106 * @param array $data Extra data.
3107 * @param string $severity Severity.
3108 */
3109 private function log( $action, $message, $data = array(), $severity = 'info' ) {
3110 if ( ! $this->activity_log ) {
3111 return;
3112 }
3113 $this->activity_log->log( 'system', $action, $message, $data, $severity );
3114 }
3115 }
3116