PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.1
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.1
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.1, at includes/class-self-integrity.php

2,514 lines 111.7 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 * A check older than this stops counting as a result. Something that stops
139 * the check (a filter, a removed hook, a cron nobody runs, files edited)
140 * leaves the last state frozen, and a green from three days ago read as
141 * "verified" would be the screen lying by omission.
142 */
143 const STALE_AFTER = 259200;
144
145 /**
146 * How often the same alarm about being switched off, or about a hook that
147 * someone removed, is written to the log and emailed.
148 */
149 const OFF_ALARM_WINDOW = 86400;
150
151 /**
152 * Settings instance
153 *
154 * @var Vigilante_Settings
155 */
156 private $settings;
157
158 /**
159 * Activity log instance
160 *
161 * @var Vigilante_Activity_Log|null
162 */
163 private $activity_log;
164
165 /**
166 * Files at the plugin root that are never in the manifest.
167 *
168 * The same rules live in bin/verify-manifest.php and in the release tool
169 * that generates the manifest, and the release checks run the three on the
170 * same set of paths and require the same verdicts.
171 *
172 * - MANIFEST.sha256: the manifest cannot contain its own hash.
173 * - readme.txt, changelog.txt: WordPress.org lets a readme be updated on
174 * a released tag without a new version, so hashing them would turn an
175 * ordinary readme fix into a tamper alarm. They are not code; the
176 * WordPress.org checksums still list them.
177 *
178 * Only at the root: a directory or a file with one of these names deeper
179 * in the tree is checked like any other.
180 *
181 * @var array
182 */
183 private static $root_excluded_files = array( 'MANIFEST.sha256', 'readme.txt', 'changelog.txt' );
184
185 /**
186 * SVN working copy metadata at the plugin root, matched exactly: only the
187 * files svn itself writes there. Anything else inside .svn/ is checked
188 * like any other file, so the folder cannot hide code (a .htaccess that
189 * maps .jpg to PHP next to a .jpg with code in it was enough before).
190 *
191 * @var string
192 */
193 private static $svn_metadata_pattern = '#^\.svn/(?:wc\.db|wc\.db-journal|format|entries|pristine/[0-9a-f]{2}/[0-9a-f]{40}\.svn-base)$#';
194
195 /**
196 * Operating system junk, skipped by file name anywhere in the tree.
197 *
198 * @var array
199 */
200 private static $junk_file_names = array( '.DS_Store', 'Thumbs.db' );
201
202 /**
203 * Extensions a web server may run as PHP: an extra file with one of these
204 * is critical, and a modified one too.
205 *
206 * @var array
207 */
208 private static $executable_extensions = array( 'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'php8', 'phps', 'pht', 'phar', 'inc' );
209
210 /**
211 * File names that change what the server runs in their folder (a handler
212 * for another extension, auto_prepend_file): treated as executable.
213 *
214 * @var array
215 */
216 private static $executable_names = array( '.htaccess', '.user.ini', 'php.ini' );
217
218 /**
219 * Constructor. Registers no hooks, see init_hooks().
220 *
221 * @param Vigilante_Settings $settings Settings instance.
222 * @param Vigilante_Activity_Log|null $activity_log Activity log instance.
223 */
224 public function __construct( $settings, $activity_log = null ) {
225 $this->settings = $settings;
226 $this->activity_log = $activity_log;
227 }
228
229 /**
230 * Register the admin_init entry points. Called once, by Vigilante_Main.
231 *
232 * Priority 20 runs after Vigilante_Admin::run_migrations() (priority 10),
233 * so an anchor captured by the migration is not processed twice.
234 */
235 public function init_hooks() {
236 add_action( 'admin_init', array( $this, 'maybe_detect_version_change' ), 20 );
237 add_action( 'admin_init', array( $this, 'maybe_run_watchdog' ), 30 );
238 // Last of all, to see whether the two above are still there: code that
239 // runs inside WordPress can unhook them, and a plugin that does that
240 // leaves every screen showing the last result as if nothing happened.
241 add_action( 'admin_init', array( $this, 'verify_hooks' ), PHP_INT_MAX );
242 }
243
244 /**
245 * Are the two entry points of the self-check still hooked?
246 *
247 * Runs at the end of admin_init, so anything that unhooked them earlier in
248 * the same request is visible here. It cannot say which file called
249 * remove_action (WordPress fires nothing when a callback is removed, and by
250 * the time this runs it already happened), so it records the short list of
251 * suspects: the plugins loaded in this request.
252 */
253 public function verify_hooks() {
254 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
255 return;
256 }
257
258 // Switched off by code: the line in the Security Audit is written from
259 // here too, not only from the daily task. Waiting for cron to say that
260 // the self-check is off is waiting a day to hear the alarm.
261 $this->audit_off_state();
262 if ( ! self::is_on() ) {
263 return;
264 }
265 $missing = array();
266 foreach ( array( 'maybe_detect_version_change', 'maybe_run_watchdog' ) as $method ) {
267 if ( false === has_action( 'admin_init', array( $this, $method ) ) ) {
268 $missing[] = $method;
269 }
270 }
271
272 $state = $this->get_state();
273 if ( empty( $missing ) ) {
274 if ( ! empty( $state['hooks_removed'] ) ) {
275 unset( $state['hooks_removed'] );
276 $this->save_state( $state );
277 }
278 return;
279 }
280
281 $state['hooks_removed'] = array(
282 'methods' => $missing,
283 'last' => time(),
284 'plugins' => self::loaded_plugins(),
285 );
286 $this->save_state( $state );
287
288 $alerted = isset( $state['hooks_alerted'] ) ? (int) $state['hooks_alerted'] : 0;
289 if ( ( time() - $alerted ) < self::OFF_ALARM_WINDOW ) {
290 return;
291 }
292 $state['hooks_alerted'] = time();
293 $this->save_state( $state );
294 $this->log(
295 'self_hooks_removed',
296 sprintf(
297 /* translators: %s: comma-separated list of removed callbacks */
298 __( 'Something removed the self-protection hooks of Vigilant in this request: %s', 'vigilante' ),
299 implode( ', ', $missing )
300 ),
301 array(
302 'methods' => $missing,
303 'plugins' => self::loaded_plugins(),
304 ),
305 'critical'
306 );
307 $this->maybe_send_self_alert(
308 array(
309 $this->finding(
310 'self_hooks_removed',
311 implode( ', ', $missing ),
312 'critical',
313 __( 'Something removed the hooks that run the self-check.', 'vigilante' )
314 ),
315 ),
316 'hooks'
317 );
318 }
319
320 /**
321 * Plugins and must-use plugins loaded in this request: the short list of
322 * suspects when something turns the self-check off.
323 *
324 * @return array
325 */
326 public static function loaded_plugins() {
327 $lista = array();
328 if ( function_exists( 'wp_get_active_and_valid_plugins' ) ) {
329 foreach ( wp_get_active_and_valid_plugins() as $ruta ) {
330 $lista[] = str_replace( WP_PLUGIN_DIR . '/', '', $ruta );
331 }
332 }
333 if ( function_exists( 'wp_get_mu_plugins' ) ) {
334 foreach ( wp_get_mu_plugins() as $ruta ) {
335 $lista[] = 'mu-plugins/' . basename( $ruta );
336 }
337 }
338 sort( $lista, SORT_STRING );
339 return array_slice( $lista, 0, 60 );
340 }
341
342 /**
343 * Files that hook the filter which switches the self-check off.
344 *
345 * Named, not guessed: each callback is resolved with Reflection to the file
346 * that declares it. Turning the check off is allowed, doing it quietly is
347 * not.
348 *
349 * @return array List of file paths, relative to the WordPress root.
350 */
351 public static function disabled_by() {
352 global $wp_filter;
353 $ficheros = array();
354 if ( empty( $wp_filter['vigilante_self_integrity_enabled'] ) || ! is_object( $wp_filter['vigilante_self_integrity_enabled'] ) ) {
355 return $ficheros;
356 }
357 foreach ( (array) $wp_filter['vigilante_self_integrity_enabled']->callbacks as $prioridad => $entradas ) {
358 foreach ( (array) $entradas as $entrada ) {
359 $llamada = isset( $entrada['function'] ) ? $entrada['function'] : null;
360 try {
361 if ( is_string( $llamada ) && function_exists( $llamada ) ) {
362 $ref = new ReflectionFunction( $llamada );
363 } elseif ( $llamada instanceof Closure ) {
364 $ref = new ReflectionFunction( $llamada );
365 } elseif ( is_array( $llamada ) && isset( $llamada[0], $llamada[1] ) ) {
366 $ref = new ReflectionMethod( is_object( $llamada[0] ) ? get_class( $llamada[0] ) : (string) $llamada[0], (string) $llamada[1] );
367 } else {
368 continue;
369 }
370 } catch ( Exception $e ) {
371 unset( $e );
372 continue;
373 } catch ( Error $e ) {
374 unset( $e );
375 continue;
376 }
377 $fichero = $ref->getFileName();
378 if ( ! $fichero ) {
379 continue;
380 }
381 $fichero = str_replace( array( ABSPATH, WP_CONTENT_DIR . '/' ), array( '', 'wp-content/' ), $fichero );
382 if ( ! in_array( $fichero, $ficheros, true ) ) {
383 $ficheros[] = $fichero;
384 }
385 }
386 }
387 return $ficheros;
388 }
389
390 /**
391 * Whether the self-check runs. It always does.
392 *
393 * There is no setting for this, and that is the point: a security plugin
394 * that can be told not to check itself has a switch whose only real user is
395 * whoever just changed its files. It was a checkbox while this was being
396 * built, it never shipped as one, and it was removed before 3.0.0 was
397 * tagged. It is not gated by the modules.file_integrity master toggle
398 * either: turning off File Integrity does not stop the plugin from
399 * checking its own files.
400 *
401 * The filter is for the one honest case, a site that must not talk to
402 * WordPress.org at all, and it is documented in SECURITY.md. It lives in
403 * code, so nobody with write access to the database can use it.
404 *
405 * @return bool
406 */
407 public static function is_on() {
408 return (bool) apply_filters( 'vigilante_self_integrity_enabled', true );
409 }
410
411 /**
412 * Instance form of is_on(), kept because most callers have the object.
413 *
414 * @return bool
415 */
416 public function is_enabled() {
417 return self::is_on();
418 }
419
420 // -------------------------------------------------------------------
421 // Paths
422 // -------------------------------------------------------------------
423
424 /**
425 * Whether a manifest path is a plain relative path inside the plugin.
426 *
427 * The manifest can be written by whoever can write to the plugin folder,
428 * and every entry is hashed from disk: an absolute path, a ".." segment or
429 * a NUL byte would turn the check into a hash oracle for any file on the
430 * server. Only letters, digits, dot, underscore, hyphen, plus and at sign,
431 * separated by single slashes, are accepted.
432 *
433 * @param string $path Relative path.
434 * @return bool
435 */
436 public static function is_safe_relative_path( $path ) {
437 if ( ! is_string( $path ) || '' === $path || strlen( $path ) > 255 ) {
438 return false;
439 }
440 if ( ! preg_match( '#^[A-Za-z0-9._@+-]+(?:/[A-Za-z0-9._@+-]+)*$#', $path ) ) {
441 return false;
442 }
443 foreach ( explode( '/', $path ) as $segment ) {
444 if ( '.' === $segment || '..' === $segment ) {
445 return false;
446 }
447 }
448 return true;
449 }
450
451 /**
452 * Whether a relative path is left out of the manifest.
453 *
454 * @param string $relative Relative path with forward slashes.
455 * @return bool
456 */
457 public static function is_excluded_path( $relative ) {
458 $relative = (string) $relative;
459 $segments = explode( '/', $relative );
460 if ( 1 === count( $segments ) && in_array( $segments[0], self::$root_excluded_files, true ) ) {
461 return true;
462 }
463 if ( preg_match( self::$svn_metadata_pattern, $relative ) ) {
464 return true;
465 }
466 return in_array( end( $segments ), self::$junk_file_names, true );
467 }
468
469 /**
470 * Whether a relative path is something a web server may run, or a file
471 * that changes what it runs.
472 *
473 * @param string $relative Relative path.
474 * @return bool
475 */
476 public static function is_executable_path( $relative ) {
477 $name = strtolower( basename( (string) $relative ) );
478 if ( in_array( $name, self::$executable_names, true ) ) {
479 return true;
480 }
481 // Every extension counts, not only the last one: with AddHandler,
482 // Apache runs x.php.jpg as PHP (mod_mime maps each extension of the
483 // name), which is how several cPanel setups configure PHP.
484 $parts = explode( '.', $name );
485 array_shift( $parts );
486 foreach ( $parts as $part ) {
487 if ( in_array( $part, self::$executable_extensions, true ) ) {
488 return true;
489 }
490 }
491 return false;
492 }
493
494 /**
495 * Whether a path is a text file whose line endings a host may rewrite.
496 *
497 * @param string $relative Relative path.
498 * @return bool
499 */
500 public static function is_text_path( $relative ) {
501 return in_array(
502 strtolower( pathinfo( (string) $relative, PATHINFO_EXTENSION ) ),
503 array( 'php', 'js', 'css', 'json', 'txt', 'md', 'html', 'htm', 'xml', 'svg', 'po', 'pot', 'ini' ),
504 true
505 );
506 }
507
508 /**
509 * sha256 of a text file with CRLF or CR line endings turned into LF and,
510 * where it changes nothing, a leading UTF-8 BOM removed.
511 *
512 * Not a copy of Vigilante_File_Integrity::hash_matches_published(), which
513 * forgives more because it judges other plugins. Here a BOM is only
514 * forgiven in files that do not care about it: in PHP it is output sent
515 * before the headers, and json_decode() rejects it, so a BOM in front of
516 * includes/scan-patterns.json empties the malware signatures while the
517 * file would look intact. Files above MAX_NORMALIZED_BYTES are not read
518 * whole: the raw hash has already failed, and the difference stays a
519 * finding instead of a fatal error that stops the whole scan.
520 *
521 * @param string $path Absolute path.
522 * @param string $relative Relative path, for the extension.
523 * @return string|null
524 */
525 private static function normalized_hash( $path, $relative ) {
526 $size = filesize( $path );
527 if ( false === $size || $size > self::MAX_NORMALIZED_BYTES ) {
528 return null;
529 }
530 // The read stops past the limit too: the size can change between the
531 // check above and the read.
532 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file of the plugin itself, read to hash it.
533 $content = file_get_contents( $path, false, null, 0, self::MAX_NORMALIZED_BYTES + 1 );
534 if ( false === $content || strlen( $content ) > self::MAX_NORMALIZED_BYTES ) {
535 return null;
536 }
537 $extension = strtolower( pathinfo( (string) $relative, PATHINFO_EXTENSION ) );
538 // The last extension is not enough: x.php.svg runs as PHP where a handler
539 // matches any extension of the name, so it keeps its BOM.
540 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 ) ) {
541 $content = substr( $content, 3 );
542 }
543 return hash( 'sha256', str_replace( array( "\r\n", "\r" ), "\n", $content ) );
544 }
545
546 /**
547 * Folder name of the plugin as installed (normally "vigilante").
548 *
549 * @return string
550 */
551 private static function plugin_folder() {
552 return dirname( VIGILANTE_PLUGIN_BASENAME );
553 }
554
555 /**
556 * Whether a path from the File Integrity ignore workflow points inside
557 * Vigilant's own folder, in either of the two conventions the scan uses
558 * (plugins/<folder>/... and the ABSPATH-relative one).
559 *
560 * Ignoring such a path silences the self-check for that file, so on a
561 * network it takes the same network rights as approving a change to
562 * wp-config.php or the root .htaccess: Vigilant's files are shared by
563 * every site, and the site that owns them reports for all of them.
564 *
565 * @param string $path Path as stored in vigilante_ignored_files.
566 * @return bool
567 */
568 public static function is_own_file_path( $path ) {
569 $path = ltrim( str_replace( '\\', '/', (string) $path ), '/' );
570 if ( '' === $path ) {
571 return false;
572 }
573 $plugin_prefix = 'plugins/' . self::plugin_folder() . '/';
574 $abspath_prefix = ltrim( str_replace( '\\', '/', str_replace( ABSPATH, '', VIGILANTE_PLUGIN_DIR ) ), '/' );
575 return 0 === strpos( $path, $plugin_prefix ) || ( '' !== $abspath_prefix && 0 === strpos( $path, $abspath_prefix ) );
576 }
577
578 // -------------------------------------------------------------------
579 // Anchors
580 // -------------------------------------------------------------------
581
582 /**
583 * Plugin root without trailing slash.
584 *
585 * @return string
586 */
587 private function get_plugin_root() {
588 return rtrim( VIGILANTE_PLUGIN_DIR, '/\\' );
589 }
590
591 /**
592 * Version of the code on disk (not the constant in memory: during an
593 * update the old code handles the hook while the new files are already
594 * on disk).
595 *
596 * @return string
597 */
598 private function get_disk_version() {
599 $main = $this->get_plugin_root() . '/' . basename( VIGILANTE_PLUGIN_BASENAME );
600 if ( is_readable( $main ) ) {
601 // 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.
602 $head = (string) file_get_contents( $main, false, null, 0, 8192 );
603 if ( preg_match( '/^\s*\*\s*Version:\s*(\S+)\s*$/m', $head, $m ) ) {
604 return $m[1];
605 }
606 }
607 return defined( 'VIGILANTE_VERSION' ) ? VIGILANTE_VERSION : '';
608 }
609
610 /**
611 * Read and parse MANIFEST.sha256 (anchor A2).
612 *
613 * A line that is not "<64 hex> <path>", an unsafe path, a repeated path,
614 * more than MAX_MANIFEST_LINES lines or a file larger than
615 * MAX_MANIFEST_BYTES make the whole manifest invalid: it is never used
616 * partially.
617 *
618 * @return array|false|null Map relative path => sha256; false when the file
619 * exists but is not a valid manifest; null when absent.
620 */
621 private function read_manifest() {
622 $raw = $this->read_manifest_raw();
623 if ( null === $raw ) {
624 return null;
625 }
626 if ( false === $raw ) {
627 return false;
628 }
629 $manifest = array();
630 $lines = 0;
631 $length = strlen( $raw );
632 $offset = 0;
633 // Line by line over the string and not explode(): a manifest made of
634 // line breaks built an array of millions of empty strings and exhausted
635 // memory before a single line was judged, which ended the whole scan
636 // and the daily check with the last status still stored. Blank lines
637 // count towards the limit for the same reason.
638 while ( $offset < $length ) {
639 $end = strpos( $raw, "\n", $offset );
640 $end = false === $end ? $length : $end;
641 $line = substr( $raw, $offset, $end - $offset );
642 $offset = $end + 1;
643 $lines++;
644 if ( $lines > self::MAX_MANIFEST_LINES ) {
645 return false;
646 }
647 if ( '' === trim( $line ) ) {
648 continue;
649 }
650 if ( ! preg_match( '/^([0-9a-f]{64}) (.+)$/', $line, $m ) ) {
651 return false;
652 }
653 if ( ! self::is_safe_relative_path( $m[2] ) || isset( $manifest[ $m[2] ] ) ) {
654 return false;
655 }
656 if ( self::is_excluded_path( $m[2] ) ) {
657 continue;
658 }
659 $manifest[ $m[2] ] = $m[1];
660 }
661 return empty( $manifest ) ? false : $manifest;
662 }
663
664 /**
665 * Manifest content of this installation, line endings normalized.
666 *
667 * @return string|false|null See read_manifest_file().
668 */
669 private function read_manifest_raw() {
670 return self::read_manifest_file( $this->get_plugin_root() . '/' . self::MANIFEST_FILE );
671 }
672
673 /**
674 * Normalized fingerprint of the MANIFEST.sha256 in a folder, taken the same
675 * way as the one anchored in the database: the sha256 of the normalized
676 * content, 'invalid' for a file larger than MAX_MANIFEST_BYTES, null when
677 * there is none (absent, unreadable or a link).
678 *
679 * vigilante_mark_upgrader_wrote() takes it from the folder the WordPress
680 * updater has just written, and the check at the end of the request only
681 * trusts the updater when the manifest on disk is still that one.
682 *
683 * @param string $dir Folder, with or without a trailing slash.
684 * @return string|null
685 */
686 public static function manifest_fingerprint_of( $dir ) {
687 $raw = self::read_manifest_file( rtrim( (string) $dir, '/\\' ) . '/' . self::MANIFEST_FILE );
688 if ( false === $raw ) {
689 return 'invalid';
690 }
691 return is_string( $raw ) ? hash( 'sha256', $raw ) : null;
692 }
693
694 /**
695 * Read a manifest file, line endings normalized.
696 *
697 * @param string $path Absolute path of a MANIFEST.sha256.
698 * @return string|false|null Null when absent, unreadable or a link; false
699 * when larger than MAX_MANIFEST_BYTES, which is
700 * not read.
701 */
702 private static function read_manifest_file( $path ) {
703 if ( is_link( $path ) || ! is_readable( $path ) ) {
704 return null;
705 }
706 $size = filesize( $path );
707 if ( false === $size || $size > self::MAX_MANIFEST_BYTES ) {
708 return false;
709 }
710 // The read stops past the limit too: the size can change between the
711 // check above and the read.
712 // 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.
713 $raw = file_get_contents( $path, false, null, 0, self::MAX_MANIFEST_BYTES + 1 );
714 if ( false === $raw ) {
715 return null;
716 }
717 if ( strlen( $raw ) > self::MAX_MANIFEST_BYTES ) {
718 return false;
719 }
720 // A host or a deploy tool that rewrites line endings rewrites the
721 // manifest too. That gives an attacker nothing (an LF manifest reads
722 // the same), so the parser and the fingerprint read it normalized.
723 if ( "\xEF\xBB\xBF" === substr( $raw, 0, 3 ) ) {
724 $raw = substr( $raw, 3 );
725 }
726 return str_replace( array( "\r\n", "\r" ), "\n", $raw );
727 }
728
729 /**
730 * SHA-256 of the manifest (the value anchored in DB as A3), line endings
731 * normalized: for the manifest the generator writes, that is the SHA-256 of
732 * the file itself.
733 *
734 * @return string|null
735 */
736 private function get_manifest_fingerprint() {
737 $raw = $this->read_manifest_raw();
738 return is_string( $raw ) ? hash( 'sha256', $raw ) : null;
739 }
740
741 /**
742 * Fetch the wp.org SHA-256 checksums for a version (anchor A1).
743 *
744 * @param string $version Plugin version.
745 * @return array|string|null Map path => array of accepted sha256 strings,
746 * 'not_found' (cached 1 h) when wp.org has no
747 * checksums for the version, or null on
748 * transient network error (not cached).
749 */
750 public function get_wporg_sha256_checksums( $version ) {
751 if ( '' === $version ) {
752 return null;
753 }
754 $transient_key = self::CHECKSUMS_TRANSIENT_PREFIX . md5( $version );
755 $cached = get_transient( $transient_key );
756 if ( 'not_found' === $cached ) {
757 return 'not_found';
758 }
759 if ( is_array( $cached ) && ! empty( $cached ) ) {
760 return $cached;
761 }
762
763 $url = 'https://downloads.wordpress.org/plugin-checksums/vigilante/' . rawurlencode( $version ) . '.json';
764 $response = wp_remote_get(
765 $url,
766 array(
767 'timeout' => self::HTTP_TIMEOUT,
768 'limit_response_size' => 1048576,
769 )
770 );
771
772 if ( is_wp_error( $response ) ) {
773 return null;
774 }
775
776 $code = (int) wp_remote_retrieve_response_code( $response );
777 if ( 200 !== $code ) {
778 // 404 is normal right after a release (checksums not generated yet)
779 // and for untagged builds: cache it for an hour so admin pageloads in
780 // that window do not hammer the API. Any other answer (429, 5xx) is
781 // WordPress.org having a bad moment, and is asked again sooner.
782 set_transient( $transient_key, 'not_found', 404 === $code ? HOUR_IN_SECONDS : 5 * MINUTE_IN_SECONDS );
783 return 'not_found';
784 }
785
786 $data = json_decode( wp_remote_retrieve_body( $response ), true );
787 if ( ! is_array( $data ) || empty( $data['files'] ) || ! is_array( $data['files'] ) ) {
788 return null;
789 }
790
791 $checksums = array();
792 foreach ( $data['files'] as $file => $sums ) {
793 if ( count( $checksums ) >= self::MAX_MANIFEST_LINES ) {
794 break;
795 }
796 $file = str_replace( '\\', '/', (string) $file );
797 if ( ! is_array( $sums ) || ! isset( $sums['sha256'] ) || ! self::is_safe_relative_path( $file ) ) {
798 continue;
799 }
800 // wp.org publishes a string, or an array when the file changed on
801 // the same tag (a readme updated after the release).
802 $checksums[ $file ] = array_map( 'strval', (array) $sums['sha256'] );
803 }
804
805 if ( empty( $checksums ) ) {
806 return null;
807 }
808
809 set_transient( $transient_key, $checksums, DAY_IN_SECONDS );
810 return $checksums;
811 }
812
813 /**
814 * Drop the cached wp.org checksums for the disk and anchored versions.
815 * Called when an update (upgrader or FTP) is detected.
816 */
817 public function flush_self_transients() {
818 $state = $this->get_state();
819 delete_transient( self::CHECKSUMS_TRANSIENT_PREFIX . md5( $this->get_disk_version() ) );
820 if ( ! empty( $state['version'] ) ) {
821 delete_transient( self::CHECKSUMS_TRANSIENT_PREFIX . md5( $state['version'] ) );
822 }
823 }
824
825 // -------------------------------------------------------------------
826 // State
827 // -------------------------------------------------------------------
828
829 /**
830 * Stored state.
831 *
832 * @return array
833 */
834 public function get_state() {
835 $state = get_option( self::STATE_OPTION, array() );
836 return is_array( $state ) ? $state : array();
837 }
838
839 /**
840 * Persist state (autoload off: only read on demand).
841 *
842 * @param array $state State array.
843 */
844 private function save_state( $state ) {
845 if ( false === get_option( self::STATE_OPTION, false ) ) {
846 add_option( self::STATE_OPTION, $state, '', false );
847 } else {
848 update_option( self::STATE_OPTION, $state, false );
849 }
850 }
851
852 /**
853 * Capture the A3 fingerprint of the current manifest.
854 *
855 * @param string $via Capture origin: activation|upgrader|version_change|migration|first_run|state_resync.
856 * @return bool Whether a fingerprint was captured (valid manifest present).
857 */
858 public function capture_fingerprint( $via ) {
859 if ( ! is_array( $this->read_manifest() ) ) {
860 return false;
861 }
862 $fingerprint = $this->get_manifest_fingerprint();
863 if ( null === $fingerprint ) {
864 return false;
865 }
866 $state = $this->get_state();
867 $state['version'] = $this->get_disk_version();
868 $state['manifest_hash'] = $fingerprint;
869 $state['captured_at'] = time();
870 $state['captured_via'] = $via;
871 unset( $state['refused_version'] );
872 $this->save_state( $state );
873 return true;
874 }
875
876 // -------------------------------------------------------------------
877 // Main check
878 // -------------------------------------------------------------------
879
880 /**
881 * Run the self-integrity check.
882 *
883 * Decision matrix per file (with a valid manifest):
884 * - in manifest, missing on disk -> self_missing (critical)
885 * - symbolic link, or resolves outside -> self_symlink (critical)
886 * - disk != manifest and != wp.org -> self_modified (critical for code, warning for assets)
887 * - disk != manifest but == wp.org -> manifest_stale (critical)
888 * - disk == manifest but != wp.org -> distribution_mismatch (warning)
889 * - on disk, not in manifest -> self_extra (critical if PHP or a link)
890 * Anchor level:
891 * - manifest hash != A3, same version -> rebaseline if wp.org confirms the manifest;
892 * manifest_replaced (critical) otherwise
893 * - manifest hash != A3, version changed -> rebaseline if the upgrader did it or wp.org confirms;
894 * manifest_replaced if wp.org contradicts it;
895 * manifest_unverified (sticky warning) otherwise;
896 * a downgrade adds self_downgraded (warning)
897 * - anchored manifest gone or invalid -> manifest_missing / manifest_invalid
898 * (critical with the same version, warning otherwise)
899 * - never anchored, manifest absent/invalid -> verify against wp.org + manifest_missing / manifest_invalid (warning)
900 * - never anchored, nothing available -> no_anchors (info), no alarm
901 *
902 * User exclusions (excluded_paths / excluded_extensions /
903 * plugin_known_false_positives) do NOT apply here: respecting them would
904 * create a silencing vector, and the plugin folder has no variable
905 * content. The explicit per-file ignore list (vigilante_ignored_files)
906 * IS respected: it is a deliberate admin action.
907 *
908 * @param string $context scan|upgrader|version_change|watchdog|migration|activation.
909 * @return array {status, findings, files_checked, anchors, rebaselined, downgraded}
910 */
911 public function run_check( $context = 'scan' ) {
912 $result = array(
913 'status' => 'disabled',
914 'findings' => array(),
915 'files_checked' => 0,
916 'anchors' => array(
917 'manifest' => false,
918 'wporg' => false,
919 'fingerprint' => false,
920 ),
921 'rebaselined' => false,
922 // Same version, manifest adopted because wp.org confirms it (3.0.1).
923 'manifest_adopted' => false,
924 'downgraded' => false,
925 );
926
927 if ( ! $this->is_enabled() ) {
928 return $result;
929 }
930
931 // An update in progress means half-copied files: skip instead of
932 // raising a false critical. The next entry point re-checks. The
933 // question is the one WordPress asks, not whether the file exists:
934 // core treats a .maintenance older than ten minutes as over
935 // (wp-includes/load.php, wp_is_maintenance_mode()), so a stale file
936 // left in the root, by accident or on purpose, must not silence
937 // the check while the site keeps serving pages.
938 if ( function_exists( 'wp_is_maintenance_mode' ) ? wp_is_maintenance_mode() : file_exists( ABSPATH . '.maintenance' ) ) {
939 $result['status'] = 'skipped';
940 return $result;
941 }
942
943 $disk_version = $this->get_disk_version();
944 $manifest = $this->read_manifest();
945 $manifest_ok = is_array( $manifest );
946 $manifest_fp = $this->get_manifest_fingerprint();
947 $wporg = $this->get_wporg_sha256_checksums( $disk_version );
948 $wporg_ok = is_array( $wporg );
949
950 $state = $this->get_state();
951 $previous_state = isset( $state['last_status'] ) ? $state['last_status'] : '';
952 $stored_fp = isset( $state['manifest_hash'] ) ? (string) $state['manifest_hash'] : '';
953 $stored_version = isset( $state['version'] ) ? (string) $state['version'] : '';
954
955 $findings = array();
956 $refused = '';
957
958 // --- Anchor A3 evaluation -------------------------------------
959 if ( $manifest_ok ) {
960 if ( '' === $stored_fp ) {
961 if ( $wporg_ok && ! $this->manifest_consistent_with_wporg( $manifest, $wporg ) ) {
962 // The first manifest seen becomes the reference for good, so
963 // it is not adopted when WordPress.org, the one reference out
964 // of an attacker's reach, distributes something else for this
965 // version: a manifest regenerated to list an added file would
966 // otherwise read as verified forever.
967 $findings[] = $this->finding(
968 'manifest_replaced',
969 self::MANIFEST_FILE,
970 'critical',
971 __( 'MANIFEST.sha256 does not describe what WordPress.org distributes for this version, so it was not adopted as the reference for this installation.', 'vigilante' )
972 );
973 $refused = (string) $disk_version;
974 } elseif ( ! $wporg_ok && '' !== (string) ( $state['refused_version'] ?? '' ) && ( (string) $state['refused_version'] === (string) $disk_version || 'upgrader' !== $context ) ) {
975 // WordPress.org already said the manifest on disk is not what
976 // it distributes. Not answering now (down, blocked, a checksum
977 // file not cached, or a version it does not publish) is no
978 // reason to adopt one: that would anchor the copy the last
979 // check refused, the same copy with its manifest touched,
980 // since a blank line changes the fingerprint and not the files,
981 // or the same copy with its Version: header raised to a number
982 // WordPress.org will never have. A genuine reinstall waits for
983 // WordPress.org to answer; only the WordPress updater writing
984 // another version is trusted, as it is for an anchored copy.
985 $findings[] = $this->finding(
986 'manifest_replaced',
987 self::MANIFEST_FILE,
988 'critical',
989 (string) $state['refused_version'] === (string) $disk_version
990 ? __( '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' )
991 : ( '' === (string) $disk_version
992 ? sprintf(
993 /* translators: %s: version WordPress.org contradicted */
994 __( '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' ),
995 (string) $state['refused_version']
996 )
997 : sprintf(
998 /* translators: 1: version WordPress.org contradicted, 2: version now on disk */
999 __( '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' ),
1000 (string) $state['refused_version'],
1001 (string) $disk_version
1002 ) )
1003 );
1004 } else {
1005 // First run with the feature: capture quietly.
1006 $this->capture_fingerprint( 'activation' === $context || 'migration' === $context ? $context : 'first_run' );
1007 $stored_fp = (string) $manifest_fp;
1008 $stored_version = $disk_version;
1009 $this->log(
1010 'self_integrity_captured',
1011 __( 'Vigilant self-protection: manifest fingerprint captured', 'vigilante' ),
1012 array( 'version' => $disk_version ),
1013 'info'
1014 );
1015 }
1016 } elseif ( $manifest_fp !== $stored_fp ) {
1017 // Same version, different manifest, whatever the context: the
1018 // upgrader hook also fires for a plugin that was not written (a
1019 // bulk update that skipped it, an install that failed before
1020 // copying), so it is not proof that WordPress replaced the files.
1021 if ( $stored_version === $disk_version ) {
1022 if ( $wporg_ok && $this->manifest_consistent_with_wporg( $manifest, $wporg ) ) {
1023 // The manifest on disk IS the one WordPress.org
1024 // distributes for this version, so whatever was anchored
1025 // before was not. That happens to a site that had a test
1026 // zip of a version and then installed the published one:
1027 // until 3.0.1 it stayed critical for good, because the
1028 // repair reinstalls those same official files and lands
1029 // here again (B45 of the roadmap, reproduced on 18 Sep
1030 // 2026 with a seeded anchor).
1031 //
1032 // Adopting it is the same argument the version-change
1033 // branch below already makes: an attacker who edits
1034 // files and regenerates the manifest cannot also make
1035 // WordPress.org's published checksums agree with it,
1036 // and that agreement is what is being checked here,
1037 // file by file and in both directions.
1038 $result['rebaselined'] = true;
1039 $result['manifest_adopted'] = true;
1040 } else {
1041 // Same version, different manifest: swapped without an update.
1042 $findings[] = $this->finding(
1043 'manifest_replaced',
1044 self::MANIFEST_FILE,
1045 'critical',
1046 __( 'MANIFEST.sha256 was replaced without a plugin update. An attacker regenerating the manifest to hide file changes would look exactly like this.', 'vigilante' )
1047 );
1048 }
1049 } else {
1050 // Version changed: the WordPress updater, a manual/FTP
1051 // update, or an attacker who bumped the Version: header to
1052 // dodge the same-version manifest_replaced check above.
1053 $wporg_confirms = $wporg_ok && $this->manifest_consistent_with_wporg( $manifest, $wporg );
1054
1055 if ( $wporg_ok && ! $wporg_confirms ) {
1056 // wp.org HAS checksums for this version and the new
1057 // manifest does not match them: tampered distribution.
1058 $findings[] = $this->finding(
1059 'manifest_replaced',
1060 self::MANIFEST_FILE,
1061 'critical',
1062 __( 'The new MANIFEST.sha256 does not match what WordPress.org distributes for this version.', 'vigilante' )
1063 );
1064 } elseif ( 'upgrader' === $context || $wporg_confirms ) {
1065 // Trusted: either the WordPress updater performed the
1066 // update, or wp.org confirms the new manifest. Adopt the
1067 // new baseline.
1068 $result['rebaselined'] = true;
1069 } else {
1070 // Version changed AND the new manifest cannot be
1071 // confirmed against WordPress.org, outside the WordPress
1072 // updater. This is the signature of an attacker who
1073 // edited files, regenerated MANIFEST.sha256 and set the
1074 // Version: header to a value wp.org will never publish.
1075 // The previous fingerprint is kept (no rebaseline) and a
1076 // sticky warning is raised until wp.org can confirm the
1077 // version or the WordPress updater installs one. A manual
1078 // update in the first hours after a release lands here
1079 // too, and resolves on its own once wp.org publishes the
1080 // checksums.
1081 $findings[] = $this->finding(
1082 'manifest_unverified',
1083 self::MANIFEST_FILE,
1084 'warning',
1085 __( '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' )
1086 );
1087 }
1088
1089 if ( '' !== $stored_version && version_compare( $disk_version, $stored_version, '<' ) ) {
1090 $result['downgraded'] = true;
1091 $findings[] = $this->finding(
1092 'self_downgraded',
1093 basename( VIGILANTE_PLUGIN_BASENAME ),
1094 'warning',
1095 sprintf(
1096 /* translators: 1: previous version, 2: current (older) version */
1097 __( 'Vigilant was downgraded from %1$s to %2$s. Older versions may contain publicly known vulnerabilities.', 'vigilante' ),
1098 $stored_version,
1099 $disk_version
1100 )
1101 );
1102 }
1103 }
1104 } elseif ( '' !== $stored_version && $stored_version !== $disk_version ) {
1105 // Fingerprint matches but the anchored version does not: the
1106 // state was restored from an old database backup while the
1107 // files (and their manifest) are current and still anchored.
1108 // Not an attack signal; resync quietly so the version change
1109 // detector converges instead of re-firing.
1110 $this->capture_fingerprint( 'state_resync' );
1111 $stored_version = $disk_version;
1112 }
1113 } elseif ( '' !== $stored_fp ) {
1114 // A manifest was anchored and now it is gone or unusable. With the
1115 // same version nothing legitimate removes it, and deleting it is
1116 // the cheapest way to hide file changes from this check.
1117 // Only the WordPress updater installing another version (a
1118 // downgrade to one without a manifest) makes it a warning; anything
1119 // else, an FTP upload with a new Version: header included, is how
1120 // file changes would be hidden, and is critical.
1121 $version_changed = '' !== $stored_version && $stored_version !== $disk_version;
1122 $severity = ( $version_changed && 'upgrader' === $context ) ? 'warning' : 'critical';
1123 if ( $version_changed && version_compare( $disk_version, $stored_version, '<' ) ) {
1124 $result['downgraded'] = true;
1125 $findings[] = $this->finding(
1126 'self_downgraded',
1127 basename( VIGILANTE_PLUGIN_BASENAME ),
1128 'warning',
1129 sprintf(
1130 /* translators: 1: previous version, 2: current (older) version */
1131 __( 'Vigilant was downgraded from %1$s to %2$s. Older versions may contain publicly known vulnerabilities.', 'vigilante' ),
1132 $stored_version,
1133 $disk_version
1134 )
1135 );
1136 }
1137 if ( false === $manifest ) {
1138 $findings[] = $this->finding(
1139 'manifest_invalid',
1140 self::MANIFEST_FILE,
1141 $severity,
1142 __( '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' )
1143 );
1144 } else {
1145 $findings[] = $this->finding(
1146 'manifest_missing',
1147 self::MANIFEST_FILE,
1148 $severity,
1149 __( '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' )
1150 );
1151 }
1152 }
1153
1154 // --- Per-file verification -------------------------------------
1155 $known = null;
1156 if ( $manifest_ok ) {
1157 $result['anchors']['manifest'] = true;
1158 $tree = $this->verify_tree( $manifest, $wporg_ok ? $wporg : null );
1159 $findings = array_merge( $findings, $tree['findings'] );
1160 $result['files_checked'] = $tree['files_checked'];
1161 $known = $manifest;
1162 } elseif ( $wporg_ok ) {
1163 if ( '' === $stored_fp ) {
1164 $findings[] = false === $manifest
1165 ? $this->finding(
1166 'manifest_invalid',
1167 self::MANIFEST_FILE,
1168 'warning',
1169 __( 'MANIFEST.sha256 is not a valid manifest. Verification degraded to the WordPress.org checksums only.', 'vigilante' )
1170 )
1171 : $this->finding(
1172 'manifest_missing',
1173 self::MANIFEST_FILE,
1174 'warning',
1175 __( 'MANIFEST.sha256 is missing from the Vigilant folder. Verification degraded to the WordPress.org checksums only.', 'vigilante' )
1176 );
1177 }
1178 $pseudo = array();
1179 foreach ( $wporg as $file => $hashes ) {
1180 if ( self::is_excluded_path( $file ) ) {
1181 continue;
1182 }
1183 $pseudo[ $file ] = $hashes; // Arrays accepted by verify_tree.
1184 }
1185 $tree = $this->verify_tree( $pseudo, null );
1186 $findings = array_merge( $findings, $tree['findings'] );
1187 $result['files_checked'] = $tree['files_checked'];
1188 $known = $pseudo;
1189 } elseif ( '' === $stored_fp ) {
1190 // Never anchored and no anchor available: degraded, informational,
1191 // never an alarm (an old install with wp.org unreachable must not
1192 // scream).
1193 $findings[] = $this->finding(
1194 'no_anchors',
1195 self::MANIFEST_FILE,
1196 'info',
1197 __( 'Neither MANIFEST.sha256 nor the WordPress.org checksums are available; the self-check cannot verify files right now.', 'vigilante' )
1198 );
1199 }
1200
1201 // --- Extra files ------------------------------------------------
1202 if ( null !== $known ) {
1203 $findings = array_merge( $findings, $this->detect_extra_files( $known ) );
1204 }
1205
1206 $result['anchors']['wporg'] = $wporg_ok;
1207 $result['anchors']['fingerprint'] = '' !== $stored_fp;
1208
1209 // --- Explicit admin ignore list ---------------------------------
1210 $findings = $this->filter_ignored_findings( $findings );
1211
1212 // --- Status aggregation ------------------------------------------
1213 $status = 'ok';
1214 $worst = $this->worst_severity( $findings );
1215 if ( 'critical' === $worst ) {
1216 $status = 'critical';
1217 } elseif ( 'warning' === $worst ) {
1218 $status = 'warning';
1219 } elseif ( ! $wporg_ok || ! $manifest_ok ) {
1220 $status = 'degraded';
1221 }
1222
1223 // --- Rebaseline A3 ------------------------------------------------
1224 if ( $result['rebaselined'] ) {
1225 $this->capture_fingerprint( 'upgrader' === $context ? 'upgrader' : 'version_change' );
1226 if ( 'upgrader' !== $context && $result['manifest_adopted'] ) {
1227 // Same version, another manifest, and wp.org confirms the one on
1228 // disk. Saying "version change" here would be false, and a log
1229 // line that consoles with something untrue is worse than no line.
1230 $this->log(
1231 'self_rebaselined',
1232 sprintf(
1233 /* translators: %s: plugin version */
1234 __( 'Vigilant self-protection: MANIFEST.sha256 of version %s adopted as the reference, confirmed by WordPress.org; baseline refreshed.', 'vigilante' ),
1235 $disk_version
1236 ),
1237 array(
1238 'previous_version' => $stored_version,
1239 'new_version' => $disk_version,
1240 ),
1241 'info'
1242 );
1243 } elseif ( 'upgrader' !== $context ) {
1244 $this->log(
1245 'self_rebaselined',
1246 $result['downgraded']
1247 ? sprintf(
1248 /* translators: %s: plugin version */
1249 __( 'Vigilant self-protection: downgrade to %s detected outside the WordPress updater and confirmed by WordPress.org; baseline refreshed.', 'vigilante' ),
1250 $disk_version
1251 )
1252 : sprintf(
1253 /* translators: %s: plugin version */
1254 __( 'Vigilant self-protection: version change detected outside the WordPress updater (manual/FTP update to %s); baseline refreshed.', 'vigilante' ),
1255 $disk_version
1256 ),
1257 array(
1258 'previous_version' => $stored_version,
1259 'new_version' => $disk_version,
1260 ),
1261 'info'
1262 );
1263 }
1264 }
1265
1266 // --- Persist ------------------------------------------------------
1267 $state = $this->get_state(); // Re-read: capture_fingerprint may have written.
1268 if ( '' !== $refused ) {
1269 $state['refused_version'] = $refused;
1270 }
1271 if ( empty( $state['manifest_hash'] ) ) {
1272 // Nothing anchored: remember the version seen, or the version
1273 // change detector would run the whole check on every call.
1274 $state['version'] = $disk_version;
1275 }
1276 $state['last_check'] = time();
1277 $state['last_context'] = $context;
1278 $state['last_status'] = $status;
1279 $state['last_findings'] = $this->cap_findings( $findings );
1280 $state['last_findings_total'] = count( $findings );
1281 $state['files_checked'] = $result['files_checked'];
1282 $state['anchors'] = $result['anchors'];
1283 if ( 'ok' === $status || 'degraded' === $status ) {
1284 $state['alerted_fingerprint'] = '';
1285 }
1286 $this->save_state( $state );
1287
1288 // A downgrade is reported by email wherever it is decided. The
1289 // updater and the version change paths send their own alert; the scan
1290 // and the daily check used to refresh the baseline in silence when
1291 // they got there first.
1292 if ( $result['downgraded'] && ! in_array( $context, array( 'upgrader', 'version_change' ), true ) ) {
1293 $this->maybe_send_self_alert( $findings, $context );
1294 }
1295
1296 // --- Log ------------------------------------------------------------
1297 if ( 'critical' === $worst || 'warning' === $worst ) {
1298 $this->log(
1299 'self_integrity_fail',
1300 sprintf(
1301 /* translators: %d: number of findings */
1302 _n(
1303 'Vigilant self-protection detected %d integrity finding in its own files',
1304 'Vigilant self-protection detected %d integrity findings in its own files',
1305 count( $findings ),
1306 'vigilante'
1307 ),
1308 count( $findings )
1309 ),
1310 array(
1311 'context' => $context,
1312 'findings' => $this->findings_summary( $findings ),
1313 ),
1314 $worst
1315 );
1316 } elseif ( in_array( $previous_state, array( 'critical', 'warning' ), true ) && in_array( $status, array( 'ok', 'degraded' ), true ) ) {
1317 $this->log(
1318 'self_integrity_restored',
1319 __( 'Vigilant self-protection: integrity restored, all files verify clean again', 'vigilante' ),
1320 array( 'context' => $context ),
1321 'info'
1322 );
1323 } elseif ( 'scan' !== $context && in_array( $status, array( 'ok', 'degraded' ), true ) ) {
1324 $this->log(
1325 'self_integrity_scan',
1326 sprintf(
1327 /* translators: 1: number of files, 2: check context */
1328 __( 'Vigilant self-check verified clean (%1$d files, context: %2$s)', 'vigilante' ),
1329 $result['files_checked'],
1330 $context
1331 ),
1332 array( 'context' => $context ),
1333 'info'
1334 );
1335 }
1336
1337 $result['status'] = $status;
1338 $result['findings'] = $findings;
1339 return $result;
1340 }
1341
1342 /**
1343 * Verify the plugin tree against a hash map.
1344 *
1345 * @param array $manifest Map path => sha256 string (or array of accepted strings).
1346 * @param array|null $wporg wp.org checksums for cross-decisions, or null.
1347 * @return array {findings, files_checked}
1348 */
1349 private function verify_tree( $manifest, $wporg ) {
1350 $root = $this->get_plugin_root();
1351 $real_root = realpath( $root );
1352 $findings = array();
1353 $checked = 0;
1354
1355 foreach ( $manifest as $relative => $expected ) {
1356 $expected_set = array_map( 'strval', (array) $expected );
1357 $path = $root . '/' . $relative;
1358 $checked++;
1359
1360 // A distributed file is never a link, and nothing listed may
1361 // resolve outside the plugin folder (a linked directory on the way).
1362 $real = realpath( $path );
1363 if ( is_link( $path ) || ( false !== $real && false !== $real_root && 0 !== strpos( $real, $real_root . DIRECTORY_SEPARATOR ) ) ) {
1364 $findings[] = $this->finding(
1365 'self_symlink',
1366 $relative,
1367 'critical',
1368 __( 'A Vigilant file is a symbolic link or resolves outside the plugin folder. Distributed files are never links.', 'vigilante' )
1369 );
1370 continue;
1371 }
1372
1373 // No exception for iCloud placeholders: WordPress does not run from
1374 // iCloud Drive, and skipping a missing file when a ".<name>.icloud"
1375 // sits next to it let anyone hide a deleted module that way.
1376 if ( ! is_file( $path ) ) {
1377 $findings[] = $this->finding(
1378 'self_missing',
1379 $relative,
1380 'critical',
1381 __( 'File listed in the Vigilant manifest is missing from disk. A deleted module silently stops protecting the site.', 'vigilante' )
1382 );
1383 continue;
1384 }
1385
1386 if ( ! is_readable( $path ) ) {
1387 $findings[] = $this->finding(
1388 'self_modified',
1389 $relative,
1390 'critical',
1391 __( 'A Vigilant file cannot be read, so it cannot be verified. Distributed files are readable; check its permissions.', 'vigilante' ),
1392 'unreadable'
1393 );
1394 continue;
1395 }
1396
1397 $actual = hash_file( 'sha256', $path );
1398 if ( ! in_array( $actual, $expected_set, true ) && self::is_text_path( $relative ) ) {
1399 // Some hosts and deploy tools rewrite text files (a UTF-8 BOM in
1400 // front, CRLF line endings) without changing a line of code. The
1401 // File Integrity scan has compared a normalized copy since 2.9.1,
1402 // and the self-check does the same, so an intact file is not
1403 // reported as tampering. A real change still fails both hashes.
1404 $normalized = self::normalized_hash( $path, $relative );
1405 if ( null !== $normalized && ( in_array( $normalized, $expected_set, true ) || ( null !== $wporg && isset( $wporg[ $relative ] ) && in_array( $normalized, $wporg[ $relative ], true ) ) ) ) {
1406 $actual = $normalized;
1407 }
1408 }
1409 if ( in_array( $actual, $expected_set, true ) ) {
1410 // Matches the manifest; cross-check the manifest itself against wp.org.
1411 if ( null !== $wporg && ! isset( $wporg[ $relative ] ) ) {
1412 // Listed in the local manifest, and not distributed by
1413 // WordPress.org at all for this version: a manifest
1414 // regenerated to include an added file looks like this.
1415 $findings[] = $this->finding(
1416 'distribution_mismatch',
1417 $relative,
1418 self::is_executable_path( $relative ) ? 'critical' : 'warning',
1419 __( 'File is listed in the local manifest, but WordPress.org does not distribute it for this version.', 'vigilante' )
1420 );
1421 } elseif ( null !== $wporg && empty( array_intersect( $expected_set, $wporg[ $relative ] ) ) ) {
1422 $findings[] = $this->finding(
1423 'distribution_mismatch',
1424 $relative,
1425 'warning',
1426 __( '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' )
1427 );
1428 }
1429 continue;
1430 }
1431
1432 if ( null !== $wporg && isset( $wporg[ $relative ] ) && in_array( $actual, $wporg[ $relative ], true ) ) {
1433 $findings[] = $this->finding(
1434 'manifest_stale',
1435 $relative,
1436 'critical',
1437 __( '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' )
1438 );
1439 } else {
1440 // Code (PHP family or JavaScript) modified is a backdoor/XSS
1441 // risk: critical. A modified non-code asset (CSS, images, data)
1442 // is far more often an optimisation plugin or a host rewriting
1443 // it in place than an attack, so it is a warning (still logged
1444 // and emailed) with wording that says so.
1445 // The data files in includes/ decide what the plugin does
1446 // (scan-patterns.json is the malware signature base): emptying
1447 // one blinds a protection as surely as editing its PHP.
1448 $extension = strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) );
1449 $is_code = self::is_executable_path( $relative ) || 'js' === $extension || ( 'json' === $extension && 0 === strpos( $relative, 'includes/' ) );
1450 if ( $is_code ) {
1451 $findings[] = $this->finding(
1452 'self_modified',
1453 $relative,
1454 'critical',
1455 __( 'Vigilant code file modified: its content no longer matches the distributed version.', 'vigilante' )
1456 );
1457 } else {
1458 $findings[] = $this->finding(
1459 'self_modified',
1460 $relative,
1461 'warning',
1462 __( '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' )
1463 );
1464 }
1465 }
1466 }
1467
1468 return array(
1469 'findings' => $findings,
1470 'files_checked' => $checked,
1471 );
1472 }
1473
1474 /**
1475 * Files, links and folders on disk that are not part of the known set.
1476 *
1477 * Harmless extra files stop being listed after MAX_EXTRA_WARNINGS, but a
1478 * link, a file a web server can be told to run and a folder that cannot
1479 * be listed are always reported, wherever they sit in the walk. The first
1480 * version counted everything against one cap of 100, so a hundred
1481 * harmless files placed where the walk starts hid a PHP file behind them.
1482 *
1483 * A folder the check cannot list (no read or no search permission) hides
1484 * what is inside it, and a file in it can still be run by its name, so it
1485 * is critical whether the distribution has that folder or not. The walk
1486 * goes on past it instead of stopping, which is what the first version
1487 * did, reporting a clean folder.
1488 *
1489 * @param array $known Map path => hash (only keys are used).
1490 * @return array Findings.
1491 */
1492 private function detect_extra_files( $known ) {
1493 $root = $this->get_plugin_root();
1494 $findings = array();
1495 $warnings = 0;
1496 $entries = 0;
1497
1498 // Folders the distribution has, from the known paths.
1499 $known_dirs = array();
1500 foreach ( array_keys( $known ) as $known_path ) {
1501 $dir = dirname( (string) $known_path );
1502 while ( '.' !== $dir && '' !== $dir && ! isset( $known_dirs[ $dir ] ) ) {
1503 $known_dirs[ $dir ] = true;
1504 $dir = dirname( $dir );
1505 }
1506 }
1507
1508 try {
1509 $iterator = new RecursiveIteratorIterator(
1510 new RecursiveDirectoryIterator( $root, RecursiveDirectoryIterator::SKIP_DOTS ),
1511 RecursiveIteratorIterator::SELF_FIRST,
1512 RecursiveIteratorIterator::CATCH_GET_CHILD
1513 );
1514 foreach ( $iterator as $file ) {
1515 $entries++;
1516 if ( $entries > self::MAX_TREE_ENTRIES ) {
1517 $findings[] = $this->finding(
1518 'self_extra',
1519 '',
1520 'critical',
1521 __( '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' ),
1522 'walk'
1523 );
1524 break;
1525 }
1526
1527 $relative = str_replace( '\\', '/', substr( $file->getPathname(), strlen( $root ) + 1 ) );
1528 $is_link = $file->isLink();
1529
1530 if ( ! $is_link && $file->isDir() ) {
1531 // On Windows is_executable() of a folder is always false (PHP asks
1532 // whether it is a program), so only readability counts there.
1533 if ( ! $file->isReadable() || ( '\\' !== DIRECTORY_SEPARATOR && ! $file->isExecutable() ) ) {
1534 $findings[] = $this->finding(
1535 'self_extra',
1536 $relative . '/',
1537 'critical',
1538 isset( $known_dirs[ $relative ] )
1539 ? __( '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' )
1540 : __( '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' ),
1541 'dir'
1542 );
1543 }
1544 continue;
1545 }
1546 if ( ! $is_link && ! $file->isFile() ) {
1547 continue;
1548 }
1549 if ( isset( $known[ $relative ] ) ) {
1550 continue;
1551 }
1552 $executable = self::is_executable_path( $relative );
1553 // Excluded names are skipped, but never a link or an executable
1554 // file, whatever name it hides behind.
1555 if ( self::is_excluded_path( $relative ) && ! $is_link && ! $executable ) {
1556 continue;
1557 }
1558
1559 if ( $is_link ) {
1560 $findings[] = $this->finding(
1561 'self_extra',
1562 $relative,
1563 'critical',
1564 __( 'Symbolic link inside the Vigilant folder that is not part of the distribution.', 'vigilante' ),
1565 'link'
1566 );
1567 continue;
1568 }
1569 if ( $executable ) {
1570 $findings[] = $this->finding(
1571 'self_extra',
1572 $relative,
1573 'critical',
1574 __( 'Executable file inside the Vigilant folder that is not part of the distribution. Injected files here run with the plugin\'s own credibility.', 'vigilante' ),
1575 'exec'
1576 );
1577 continue;
1578 }
1579 if ( $warnings >= self::MAX_EXTRA_WARNINGS ) {
1580 continue;
1581 }
1582 $warnings++;
1583 $findings[] = $this->finding(
1584 'self_extra',
1585 $relative,
1586 'warning',
1587 __( 'File inside the Vigilant folder that is not part of the distribution.', 'vigilante' )
1588 );
1589 }
1590 } catch ( Exception $e ) {
1591 // The folder itself could not be opened: say so instead of
1592 // reporting a clean folder.
1593 unset( $e );
1594 $findings[] = $this->finding(
1595 'self_extra',
1596 '',
1597 'critical',
1598 __( 'The Vigilant folder could not be walked, so files added to it cannot be detected.', 'vigilante' ),
1599 'walk'
1600 );
1601 }
1602
1603 return $findings;
1604 }
1605
1606 /**
1607 * Whether the local manifest describes the same tree wp.org distributes.
1608 *
1609 * @param array $manifest Map path => sha256.
1610 * @param array $wporg Map path => array of sha256.
1611 * @return bool
1612 */
1613 private function manifest_consistent_with_wporg( $manifest, $wporg ) {
1614 foreach ( $manifest as $file => $hash ) {
1615 if ( ! isset( $wporg[ $file ] ) || ! in_array( (string) $hash, $wporg[ $file ], true ) ) {
1616 return false;
1617 }
1618 }
1619 foreach ( $wporg as $file => $hashes ) {
1620 if ( self::is_excluded_path( $file ) ) {
1621 continue;
1622 }
1623 if ( ! isset( $manifest[ $file ] ) ) {
1624 return false;
1625 }
1626 }
1627 return true;
1628 }
1629
1630 /**
1631 * Drop findings the admin explicitly ignored via the File Integrity
1632 * ignore workflow. Only per-file findings can be ignored; anchor-level
1633 * findings (manifest_replaced, manifest_missing, no_anchors...) always
1634 * surface.
1635 *
1636 * @param array $findings Findings.
1637 * @return array
1638 */
1639 private function filter_ignored_findings( $findings ) {
1640 $ignored = get_option( 'vigilante_ignored_files', array() );
1641 if ( empty( $ignored ) || ! is_array( $ignored ) ) {
1642 return $findings;
1643 }
1644 $per_file = array( 'self_modified', 'self_missing', 'self_extra', 'self_symlink', 'manifest_stale', 'distribution_mismatch' );
1645 $plugin_prefix = 'plugins/' . self::plugin_folder() . '/';
1646 $abspath_prefix = str_replace( ABSPATH, '', VIGILANTE_PLUGIN_DIR );
1647 return array_values(
1648 array_filter(
1649 $findings,
1650 function ( $finding ) use ( $ignored, $per_file, $plugin_prefix, $abspath_prefix ) {
1651 if ( ! in_array( $finding['code'], $per_file, true ) ) {
1652 return true;
1653 }
1654 // The walk findings (a folder that cannot be listed, the whole
1655 // folder that could not be walked, too many entries) are not
1656 // about one file: no entry of the list hides them.
1657 $file = (string) $finding['file'];
1658 if ( '' === $file || '/' === substr( $file, -1 ) ) {
1659 return true;
1660 }
1661 // Both path conventions used by the File Integrity UI.
1662 return ! in_array( $plugin_prefix . $finding['file'], $ignored, true ) && ! in_array( $abspath_prefix . $finding['file'], $ignored, true );
1663 }
1664 )
1665 );
1666 }
1667
1668 // -------------------------------------------------------------------
1669 // Update paths (#51)
1670 // -------------------------------------------------------------------
1671
1672 /**
1673 * Immediate verification after the WordPress upgrader updated Vigilant.
1674 * Called (already filtered to this plugin) from
1675 * vigilante_on_upgrader_process_complete() in vigilante.php. The OLD code
1676 * runs this handler while the NEW files are on disk, hence everything is
1677 * read from disk, never from in-memory constants.
1678 *
1679 * @param bool $written Whether WordPress wrote the plugin folder in this
1680 * request (see vigilante_mark_upgrader_wrote()).
1681 */
1682 public function handle_upgrader( $written = true ) {
1683 if ( ! $this->is_enabled() ) {
1684 return;
1685 }
1686
1687 $this->flush_self_transients();
1688 // Only when WordPress wrote the plugin folder in this request is the
1689 // updater proof of anything: a bulk update fires the hook for plugins it
1690 // skipped too. Otherwise it is a version change like any other.
1691 $result = $this->run_check( $written ? 'upgrader' : 'version_change' );
1692
1693 $worst = $this->worst_severity( $result['findings'] );
1694 $this->log(
1695 'self_verified_post_update',
1696 sprintf(
1697 /* translators: 1: plugin version, 2: verification status */
1698 __( 'Vigilant verified its own files right after updating to %1$s: %2$s', 'vigilante' ),
1699 $this->get_disk_version(),
1700 $result['status']
1701 ),
1702 array(
1703 'status' => $result['status'],
1704 'findings' => $this->findings_summary( $result['findings'] ),
1705 ),
1706 'critical' === $worst ? 'critical' : 'info'
1707 );
1708
1709 if ( 'critical' === $worst || $result['downgraded'] ) {
1710 $this->maybe_send_self_alert( $result['findings'], 'upgrader' );
1711 }
1712 }
1713
1714 /**
1715 * Version change detection from admin_init (priority 20).
1716 *
1717 * admin_init also fires on admin-post.php before anybody is identified,
1718 * and for logged-in users without any capability, so this only runs for
1719 * a user who can manage options: the check hashes the tree, writes the
1720 * state and can send an email, none of which an anonymous request may
1721 * trigger. The same guard as Vigilante_Admin::run_migrations().
1722 */
1723 public function maybe_detect_version_change() {
1724 if ( wp_doing_ajax() || wp_doing_cron() ) {
1725 return;
1726 }
1727 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
1728 return;
1729 }
1730 $this->detect_version_change();
1731 }
1732
1733 /**
1734 * FTP/manual update and downgrade detection: the anchored version in the
1735 * state differs from the running code. Also called from the daily
1736 * maintenance cron. Throttled while a change stays unresolved.
1737 */
1738 public function detect_version_change() {
1739 if ( ! $this->is_enabled() ) {
1740 return;
1741 }
1742
1743 $state = $this->get_state();
1744 $stored_version = isset( $state['version'] ) ? (string) $state['version'] : '';
1745 // The version on disk, not the constant in memory: run_check() stores
1746 // the one on disk, and with an opcode cache still serving the old files
1747 // the two differ, so comparing with the constant never converged.
1748 $running = $this->get_disk_version();
1749
1750 if ( '' !== $stored_version && $running === $stored_version ) {
1751 return;
1752 }
1753
1754 if ( get_transient( self::VERSION_CHECK_TRANSIENT ) ) {
1755 return;
1756 }
1757 set_transient( self::VERSION_CHECK_TRANSIENT, 1, self::VERSION_CHECK_THROTTLE );
1758
1759 if ( '' === $stored_version ) {
1760 // Feature just arrived (fresh install handled by the activator,
1761 // updates by the 3.0.0 migration); this is the belt-and-braces
1762 // path. run_check() captures quietly.
1763 $this->run_check( 'version_change' );
1764 return;
1765 }
1766
1767 // Flush the cached checksums once per detected version, not on every
1768 // throttled retry, so a version wp.org has not published yet is asked
1769 // for again only when its not_found cache expires.
1770 if ( ! isset( $state['pending_version'] ) || $running !== $state['pending_version'] ) {
1771 $this->flush_self_transients();
1772 $state['pending_version'] = $running;
1773 $this->save_state( $state );
1774 }
1775
1776 $result = $this->run_check( 'version_change' );
1777
1778 // Alert on a confirmed tamper (critical), a downgrade, or an
1779 // unverifiable manifest change (the forged-version bypass signature),
1780 // so a version change that cannot be vouched for never passes silently.
1781 $has_unverified = false;
1782 foreach ( $result['findings'] as $finding ) {
1783 if ( 'manifest_unverified' === $finding['code'] ) {
1784 $has_unverified = true;
1785 break;
1786 }
1787 }
1788 if ( 'critical' === $this->worst_severity( $result['findings'] ) || $result['downgraded'] || $has_unverified ) {
1789 $this->maybe_send_self_alert( $result['findings'], 'version_change' );
1790 }
1791 }
1792
1793 // -------------------------------------------------------------------
1794 // Cron watchdog (#52)
1795 // -------------------------------------------------------------------
1796
1797 /**
1798 * Throttled watchdog entry point (admin_init priority 30), for a user who
1799 * can manage options only (see maybe_detect_version_change()). The second
1800 * entry point is daily_maintenance() in vigilante.php, for sites where
1801 * nobody visits wp-admin.
1802 */
1803 public function maybe_run_watchdog() {
1804 if ( wp_doing_ajax() || wp_doing_cron() ) {
1805 return;
1806 }
1807 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
1808 return;
1809 }
1810 if ( ! $this->is_enabled() || get_transient( self::WATCHDOG_TRANSIENT ) ) {
1811 return;
1812 }
1813 set_transient( self::WATCHDOG_TRANSIENT, 1, 6 * HOUR_IN_SECONDS );
1814 $this->run_watchdog();
1815 }
1816
1817 /**
1818 * Verify that Vigilant's own cron events are still scheduled and restore
1819 * any that were unscheduled (a malicious wp_clear_scheduled_hook, or an
1820 * overzealous cleanup plugin, silences the plugin forever otherwise).
1821 *
1822 * A hook is only reported once the watchdog has seen it scheduled: the
1823 * first pass on a site, and a hook whose setting was just switched on,
1824 * schedule what is missing quietly. After that, a first disappearance is
1825 * restored with a warning; the SAME hook disappearing again within 30
1826 * days means something is actively clearing it: critical and a standalone
1827 * alert. A hook whose setting is off is forgotten, so switching it back on
1828 * is not reported either.
1829 *
1830 * On a network it only watches the site that owns the installation's
1831 * shared files: the activation schedules these events once, so on the
1832 * other sites "not scheduled" does not mean "removed". The events of the
1833 * other sites are left for the multisite release.
1834 *
1835 * Runs only while the plugin is active, so legitimate deactivation (which
1836 * clears these events in Vigilante_Deactivator) makes no noise, and no
1837 * new cron of its own is ever registered. The password-expiry reminder
1838 * (vigilante_password_expiry_reminder) is deliberately NOT in this table:
1839 * User Security schedules it according to its own toggle.
1840 *
1841 * As a fallback for sites with the File Integrity module (and therefore
1842 * the scan) disabled, a daily self-check also runs from here.
1843 */
1844 public function run_watchdog() {
1845 $fi = $this->settings ? $this->settings->get_section( 'file_integrity' ) : array();
1846 $modules = $this->settings ? $this->settings->get_section( 'modules' ) : array();
1847 $analyzer = $this->settings ? $this->settings->get_section( 'security_analyzer' ) : array();
1848
1849 if ( Vigilante_Settings::owns_shared_files() ) {
1850 // hook => array( schedule, first-run offset, required? ).
1851 // The weekly analyzer scan is required unless it was switched off
1852 // explicitly: its own toggle unschedules it on purpose, and an
1853 // unset value means on (Vigilante_Security_Analyzer reads it the same way).
1854 $expected = array(
1855 'vigilante_daily_maintenance' => array( 'daily', 0, true ),
1856 'vigilante_hourly_checks' => array( 'hourly', 0, true ),
1857 'vigilante_analyzer_weekly_scan' => array( 'weekly', DAY_IN_SECONDS, ! isset( $analyzer['weekly_scan_enabled'] ) || ! empty( $analyzer['weekly_scan_enabled'] ) ),
1858 'vigilante_plugin_status_check' => array( 'daily', HOUR_IN_SECONDS, ! empty( $fi['check_closed_plugins'] ) ),
1859 'vigilante_file_integrity_scan' => array(
1860 isset( $fi['scan_frequency'] ) ? $fi['scan_frequency'] : 'daily',
1861 0,
1862 ! empty( $modules['file_integrity'] ) && ! empty( $fi['auto_scan'] ),
1863 ),
1864 );
1865
1866 // A recurrence nobody registered (an imported or edited setting)
1867 // cannot be scheduled: use daily instead of failing on every pass.
1868 $schedules = wp_get_schedules();
1869 foreach ( $expected as $hook => $spec ) {
1870 if ( ! isset( $schedules[ $spec[0] ] ) ) {
1871 $expected[ $hook ][0] = 'daily';
1872 }
1873 }
1874
1875 $state = $this->get_state();
1876 $watchdog = ( isset( $state['watchdog'] ) && is_array( $state['watchdog'] ) ) ? $state['watchdog'] : array();
1877 $first_pass = ! isset( $state['watchdog_seen'] ) || ! is_array( $state['watchdog_seen'] );
1878 $seen = $first_pass ? array() : $state['watchdog_seen'];
1879 $baselined = array();
1880 $restored = array();
1881 $repeated = array();
1882 $now = time();
1883
1884 foreach ( $expected as $hook => $spec ) {
1885 list( $schedule, $offset, $required ) = $spec;
1886
1887 if ( ! $required ) {
1888 unset( $seen[ $hook ], $watchdog[ $hook ] );
1889 continue;
1890 }
1891 if ( wp_next_scheduled( $hook ) ) {
1892 $seen[ $hook ] = true;
1893 continue;
1894 }
1895
1896 $scheduled = wp_schedule_event( $now + $offset, $schedule, $hook );
1897 if ( false === $scheduled || is_wp_error( $scheduled ) ) {
1898 // Not scheduled, so it cannot be counted as restored: the
1899 // next pass tries again without reporting a removal.
1900 continue;
1901 }
1902
1903 if ( empty( $seen[ $hook ] ) ) {
1904 $seen[ $hook ] = true;
1905 $baselined[] = $hook;
1906 continue;
1907 }
1908
1909 $previous = ( isset( $watchdog[ $hook ] ) && is_array( $watchdog[ $hook ] ) ) ? $watchdog[ $hook ] : array();
1910 $recent = isset( $previous['last'] ) && ( $now - (int) $previous['last'] ) < self::WATCHDOG_REPEAT_WINDOW;
1911 $times = $recent && isset( $previous['count'] ) ? (int) $previous['count'] + 1 : 1;
1912
1913 $watchdog[ $hook ] = array(
1914 'count' => $times,
1915 'last' => $now,
1916 );
1917 $restored[] = $hook;
1918 if ( $times >= 2 ) {
1919 $repeated[] = $hook;
1920 }
1921 }
1922
1923 $state['watchdog'] = $watchdog;
1924 $state['watchdog_seen'] = $seen;
1925 $this->save_state( $state );
1926
1927 if ( ! empty( $baselined ) ) {
1928 $this->log(
1929 'cron_scheduled',
1930 sprintf(
1931 /* translators: %s: comma-separated list of cron hooks */
1932 __( 'Vigilant watchdog scheduled cron events that were not scheduled yet: %s', 'vigilante' ),
1933 implode( ', ', $baselined )
1934 ),
1935 array( 'scheduled' => $baselined ),
1936 'info'
1937 );
1938 }
1939
1940 if ( ! empty( $restored ) ) {
1941 $this->log(
1942 'cron_restored',
1943 sprintf(
1944 /* translators: %s: comma-separated list of cron hooks */
1945 __( 'Vigilant watchdog re-scheduled missing cron events: %s', 'vigilante' ),
1946 implode( ', ', $restored )
1947 ),
1948 array(
1949 'restored' => $restored,
1950 'recurrence' => $watchdog,
1951 ),
1952 empty( $repeated ) ? 'warning' : 'critical'
1953 );
1954 }
1955
1956 if ( ! empty( $repeated ) ) {
1957 $findings = array();
1958 foreach ( $repeated as $hook ) {
1959 $findings[] = $this->finding(
1960 'cron_cleared_repeatedly',
1961 $hook,
1962 'critical',
1963 sprintf(
1964 /* translators: 1: cron hook name, 2: number of restorations */
1965 __( 'The cron event "%1$s" keeps being unscheduled (restored %2$d times in 30 days). Something on this site is actively clearing Vigilant\'s scheduled tasks.', 'vigilante' ),
1966 $hook,
1967 (int) $watchdog[ $hook ]['count']
1968 )
1969 );
1970 }
1971 $this->maybe_send_self_alert( $findings, 'watchdog' );
1972 }
1973 }
1974
1975 // Fallback file self-check whenever nothing checked the files in the
1976 // last day: the File Integrity module switched off, scheduled scans
1977 // switched off, or a weekly schedule. Asking only for the module left a
1978 // site with scheduled scans switched off with no periodic check at all.
1979 $state = $this->get_state();
1980 $last_check = isset( $state['last_check'] ) ? (int) $state['last_check'] : 0;
1981 if ( time() - $last_check > DAY_IN_SECONDS ) {
1982 $result = $this->run_check( 'watchdog' );
1983 if ( 'critical' === $this->worst_severity( $result['findings'] ) ) {
1984 $this->maybe_send_self_alert( $result['findings'], 'watchdog' );
1985 }
1986 }
1987 }
1988
1989 // -------------------------------------------------------------------
1990 // Alerts and integration surface
1991 // -------------------------------------------------------------------
1992
1993 /**
1994 * Standalone alert for the upgrader, version change and watchdog paths.
1995 *
1996 * Deliberately NOT gated by the File Integrity instant_alert toggle
1997 * (unlike Vigilante_Plugin_Status): a tamper of the guardian itself is
1998 * the product's maximum alarm. There is no setting that silences it; only
1999 * the filter of is_on(), which SECURITY.md documents. Findings raised by a
2000 * File Integrity scan come through here too: switching off the report about
2001 * changed files must not switch off the alarm about the plugin itself.
2002 *
2003 * Sent only from the site that owns the installation's shared files: the
2004 * plugin files are the same for every site and every network of an
2005 * installation, so one email. Deduped by a fingerprint of the finding
2006 * set; cleared when back to ok.
2007 *
2008 * @param array $findings Findings to report.
2009 * @param string $context Alert context (upgrader|version_change|watchdog).
2010 */
2011 public function maybe_send_self_alert( $findings, $context ) {
2012 if ( empty( $findings ) ) {
2013 return;
2014 }
2015
2016 if ( ! Vigilante_Settings::owns_shared_files() ) {
2017 return;
2018 }
2019 $summary = $this->findings_summary( $findings );
2020 $fingerprint = md5( wp_json_encode( $summary ) );
2021 $state = $this->get_state();
2022 if ( isset( $state['alerted_fingerprint'] ) && $fingerprint === $state['alerted_fingerprint'] ) {
2023 return;
2024 }
2025
2026 if ( ! class_exists( 'Vigilante_Email_Template' ) ) {
2027 require_once VIGILANTE_INCLUDES_DIR . 'class-email-template.php';
2028 }
2029 $recipients = Vigilante_Email_Template::get_admin_recipients();
2030 // On a network the notification recipients are settings of the main
2031 // site, which its administrator can change without network rights, so
2032 // the network administration email is added to every one of these
2033 // alerts. One send, one fingerprint: the set of findings is what is
2034 // deduped, for the site addresses and for the network one alike.
2035 if ( is_multisite() ) {
2036 $network_email = get_site_option( 'admin_email' );
2037 if ( is_email( $network_email ) ) {
2038 $recipients[] = $network_email;
2039 }
2040 $recipients = array_values( array_unique( $recipients ) );
2041 }
2042 if ( empty( $recipients ) ) {
2043 return;
2044 }
2045
2046 $worst = $this->worst_severity( $findings );
2047 $site_name = get_bloginfo( 'name' );
2048
2049 if ( 'critical' === $worst ) {
2050 $subject = sprintf(
2051 /* translators: %s: Site name */
2052 __( '[%s] CRITICAL: Vigilant plugin files were modified', 'vigilante' ),
2053 $site_name
2054 );
2055 $title = __( 'Vigilant self-protection alert', 'vigilante' );
2056 $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' );
2057 } else {
2058 $subject = sprintf(
2059 /* translators: %s: Site name */
2060 __( '[%s] Vigilant self-protection warning', 'vigilante' ),
2061 $site_name
2062 );
2063 $title = __( 'Vigilant self-protection warning', 'vigilante' );
2064 $intro = __( 'Vigilant detected a change in its own installation that deserves your attention.', 'vigilante' );
2065 }
2066
2067 $body = Vigilante_Email_Template::p( $intro );
2068 $body .= self::build_self_email_section( $findings );
2069 $body .= Vigilante_Email_Template::p(
2070 __( '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' )
2071 );
2072 $body .= Vigilante_Email_Template::button(
2073 admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-last-scan' ),
2074 __( 'Review in Vigilant', 'vigilante' )
2075 );
2076 $body .= Vigilante_Email_Template::small(
2077 sprintf(
2078 /* translators: %s: where the check ran (upgrader, version_change or watchdog) */
2079 __( 'This alert is part of Vigilant self-protection and does not depend on the instant alert setting. Context: %s', 'vigilante' ),
2080 $context
2081 )
2082 );
2083
2084 $delivered = Vigilante_Email_Template::send( $recipients, $subject, $title, $body, true );
2085 if ( ! $delivered && 1 === count( (array) $recipients ) ) {
2086 // One address, so false means that address did not get it: nothing
2087 // is marked, and the next check takes the alert to it again. With
2088 // several recipients false is not proof of anything (PHPMailer
2089 // returns it when one of them is refused and the others were sent),
2090 // so those count as sent, or the alert would be repeated to
2091 // everybody who did get it.
2092 return;
2093 }
2094
2095 $state['alerted_fingerprint'] = $fingerprint;
2096 $this->save_state( $state );
2097 }
2098
2099 /**
2100 * Red email sub-section for self findings, mirroring the closed-plugins
2101 * section style in the File Integrity digest.
2102 *
2103 * @param array $findings Findings.
2104 * @return string HTML.
2105 */
2106 public static function build_self_email_section( $findings ) {
2107 if ( empty( $findings ) ) {
2108 return '';
2109 }
2110
2111 $html = '<div style="background:#fef1f1;border-left:4px solid #d63638;padding:12px 16px;margin:16px 0;">';
2112 $html .= '<h2 style="margin:0 0 8px;font-size:16px;color:#d63638;">' . esc_html__( 'Vigilant self-protection', 'vigilante' ) . '</h2>';
2113 $html .= '<p style="margin:0 0 10px;color:#3c434a;">' . esc_html__( 'Findings about Vigilant\'s own files. If you did not modify them yourself, treat this as a possible compromise.', 'vigilante' ) . '</p>';
2114 $html .= '<table style="border-collapse:collapse;width:100%;font-size:13px;">';
2115 $html .= '<tr><th style="text-align:left;padding:4px 8px;border-bottom:1px solid #dcdcde;">' . esc_html__( 'File', 'vigilante' ) . '</th>'
2116 . '<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #dcdcde;">' . esc_html__( 'Finding', 'vigilante' ) . '</th>'
2117 . '<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #dcdcde;">' . esc_html__( 'Severity', 'vigilante' ) . '</th></tr>';
2118
2119 foreach ( array_slice( $findings, 0, self::MAX_STORED_FINDINGS ) as $finding ) {
2120 $html .= '<tr>'
2121 . '<td style="padding:4px 8px;border-bottom:1px solid #f0f0f1;"><code>' . esc_html( $finding['file'] ) . '</code></td>'
2122 . '<td style="padding:4px 8px;border-bottom:1px solid #f0f0f1;">' . esc_html( self::finding_label( $finding['code'] ) ) . '</td>'
2123 . '<td style="padding:4px 8px;border-bottom:1px solid #f0f0f1;">' . esc_html( $finding['severity'] ) . '</td>'
2124 . '</tr>';
2125 }
2126
2127 $html .= '</table>';
2128
2129 /*
2130 * The same catalogue the screens use: an email that reports tampering
2131 * in the security plugin and leaves the reader with no next step is
2132 * half a tool. One block per kind of finding, worst first, up to three.
2133 */
2134 $seen = array();
2135 $shown = 0;
2136 foreach ( $findings as $finding ) {
2137 $guidance = Vigilante_Self_Integrity_Guidance::for_finding( $finding );
2138 if ( isset( $seen[ $guidance['key'] ] ) || $shown >= 3 ) {
2139 continue;
2140 }
2141 $seen[ $guidance['key'] ] = true;
2142 ++$shown;
2143
2144 $html .= '<p style="margin:12px 0 0;color:#3c434a;"><strong>' . esc_html( $guidance['title'] ) . '.</strong> '
2145 . esc_html( $guidance['meaning'] ) . '</p>';
2146 if ( ! empty( $guidance['steps'] ) ) {
2147 $html .= '<p style="margin:6px 0 0;color:#3c434a;"><strong>' . esc_html__( 'What to do:', 'vigilante' ) . '</strong></p>'
2148 . '<ol style="margin:4px 0 0;padding-left:20px;color:#3c434a;">';
2149 foreach ( $guidance['steps'] as $step ) {
2150 $html .= '<li style="margin-bottom:4px;">' . esc_html( $step ) . '</li>';
2151 }
2152 $html .= '</ol>';
2153 }
2154 }
2155
2156 $html .= '<p style="margin:12px 0 0;"><a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' ) ) . '">'
2157 . esc_html__( 'Open File Integrity in your site', 'vigilante' ) . '</a></p>';
2158 $html .= '</div>';
2159 return $html;
2160 }
2161
2162 /**
2163 * State to show on a screen of this site.
2164 *
2165 * The files belong to the whole network but the state option is per site,
2166 * so the most recent of the two checks that can see those files (this site
2167 * and the main site of the main network) is the one that describes them.
2168 * Without this, the main site can report tampering while a subsite keeps
2169 * showing the clean result it wrote days ago.
2170 *
2171 * @return array
2172 */
2173 public static function display_state() {
2174 $state = get_option( self::STATE_OPTION, array() );
2175 $state = is_array( $state ) ? $state : array();
2176 if ( ! is_multisite() ) {
2177 return $state;
2178 }
2179 $main_id = (int) get_main_site_id( get_main_network_id() );
2180 if ( (int) get_current_blog_id() === $main_id ) {
2181 return $state;
2182 }
2183 $main = get_blog_option( $main_id, self::STATE_OPTION, array() );
2184 $main = is_array( $main ) ? $main : array();
2185 $here = isset( $state['last_check'] ) ? (int) $state['last_check'] : 0;
2186 $there = isset( $main['last_check'] ) ? (int) $main['last_check'] : 0;
2187 return ( $there > $here ) ? $main : $state;
2188 }
2189
2190 /**
2191 * Cron events the watchdog had to schedule again more than once inside its
2192 * window. They are logged and emailed when they happen, but they are not
2193 * part of last_findings, so the screens read them from the watchdog state.
2194 *
2195 * @param array $state State.
2196 * @return array Findings, in the same shape as last_findings.
2197 */
2198 public static function watchdog_findings( $state ) {
2199 $findings = array();
2200 $watchdog = ( isset( $state['watchdog'] ) && is_array( $state['watchdog'] ) ) ? $state['watchdog'] : array();
2201 foreach ( $watchdog as $hook => $entry ) {
2202 if ( ! is_array( $entry ) ) {
2203 continue;
2204 }
2205 $count = isset( $entry['count'] ) ? (int) $entry['count'] : 0;
2206 $last = isset( $entry['last'] ) ? (int) $entry['last'] : 0;
2207 if ( $count < 2 || ( time() - $last ) >= self::WATCHDOG_REPEAT_WINDOW ) {
2208 continue;
2209 }
2210 $findings[] = array(
2211 'code' => 'cron_cleared_repeatedly',
2212 'file' => (string) $hook,
2213 'severity' => 'critical',
2214 'message' => '',
2215 );
2216 }
2217 return $findings;
2218 }
2219
2220 /**
2221 * Findings of a state (stored plus watchdog), or none when the check is off.
2222 *
2223 * @param array $state State.
2224 * @param bool $enabled Whether the self-check runs (see is_on()).
2225 * @return array
2226 */
2227 public static function state_findings( $state, $enabled = true ) {
2228 $findings = array();
2229
2230 /*
2231 * Being switched off is a finding, and a critical one. It takes code on
2232 * the site to do it, which is either an administrator who decided it or
2233 * something that does not want to be checked; either way the screens say
2234 * so, with the files that hook the filter, instead of a grey line.
2235 */
2236 if ( ! $enabled ) {
2237 $ficheros = self::disabled_by();
2238 if ( empty( $ficheros ) ) {
2239 $ficheros = array( '' );
2240 }
2241 foreach ( $ficheros as $fichero ) {
2242 $findings[] = array(
2243 'code' => 'self_disabled',
2244 'file' => $fichero,
2245 'severity' => 'critical',
2246 'message' => '',
2247 );
2248 }
2249 return $findings;
2250 }
2251
2252 // Hooks that somebody removed in a request: same tier.
2253 if ( ! empty( $state['hooks_removed']['methods'] ) && is_array( $state['hooks_removed']['methods'] ) ) {
2254 foreach ( $state['hooks_removed']['methods'] as $metodo ) {
2255 $findings[] = array(
2256 'code' => 'self_hooks_removed',
2257 'file' => (string) $metodo,
2258 'severity' => 'critical',
2259 'message' => '',
2260 );
2261 }
2262 }
2263
2264 $stored = ( isset( $state['last_findings'] ) && is_array( $state['last_findings'] ) ) ? $state['last_findings'] : array();
2265
2266 // A result older than STALE_AFTER is not a result: say so instead of
2267 // repeating the green of the last check that did run.
2268 if ( ! empty( $state['last_check'] ) && ( time() - (int) $state['last_check'] ) > self::STALE_AFTER ) {
2269 $findings[] = array(
2270 'code' => 'self_stale',
2271 'file' => '',
2272 'severity' => 'warning',
2273 'message' => '',
2274 );
2275 }
2276
2277 return array_merge( $findings, $stored, self::watchdog_findings( $state ) );
2278 }
2279
2280 /**
2281 * Tone of the state: what colours the File Integrity box, the Security
2282 * Check result, the menu counter and the notices. One function, so the four
2283 * never disagree.
2284 *
2285 * @param array $state State.
2286 * @param bool $enabled Whether the self-check runs (see is_on()).
2287 * @return string critical|warning|info|ok|off|none
2288 */
2289 public static function tone( $state, $enabled = true ) {
2290 if ( ! $enabled ) {
2291 // Switched off by code is an alarm, not a neutral state: see
2292 // state_findings().
2293 return 'off';
2294 }
2295 if ( ! empty( $state['hooks_removed']['methods'] ) ) {
2296 return 'critical';
2297 }
2298 if ( empty( $state['last_check'] ) ) {
2299 return 'none';
2300 }
2301 $findings = self::state_findings( $state, true );
2302 $worst = 'none';
2303 foreach ( $findings as $finding ) {
2304 $severity = isset( $finding['severity'] ) ? (string) $finding['severity'] : '';
2305 if ( 'critical' === $severity ) {
2306 $worst = 'critical';
2307 break;
2308 }
2309 if ( 'warning' === $severity ) {
2310 $worst = 'warning';
2311 }
2312 }
2313 $status = isset( $state['last_status'] ) ? (string) $state['last_status'] : '';
2314 if ( 'critical' === $worst || 'critical' === $status ) {
2315 return 'critical';
2316 }
2317 $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
2318 if ( 'warning' === $worst || 'warning' === $status || $files < 1 ) {
2319 return 'warning';
2320 }
2321 $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
2322 return ( count( array_filter( $anchors ) ) >= 3 ) ? 'ok' : 'info';
2323 }
2324
2325 /**
2326 * Says out loud, once a day, that the self-check is switched off.
2327 *
2328 * Runs from the daily maintenance, which is not gated by is_on(): with the
2329 * check off nothing else would write a line, and an installation that
2330 * stopped checking itself in silence is exactly what this release is about.
2331 */
2332 public function audit_off_state() {
2333 if ( self::is_on() ) {
2334 return;
2335 }
2336 $state = $this->get_state();
2337 $alerted = isset( $state['off_alerted'] ) ? (int) $state['off_alerted'] : 0;
2338 if ( ( time() - $alerted ) < self::OFF_ALARM_WINDOW ) {
2339 return;
2340 }
2341 $state['off_alerted'] = time();
2342 $this->save_state( $state );
2343
2344 $ficheros = self::disabled_by();
2345 $this->maybe_send_self_alert(
2346 array(
2347 $this->finding(
2348 'self_disabled',
2349 $ficheros ? implode( ', ', $ficheros ) : '',
2350 'critical',
2351 __( 'Vigilant self-protection is switched off by code on this site.', 'vigilante' )
2352 ),
2353 ),
2354 'disabled'
2355 );
2356 $this->log(
2357 'self_disabled',
2358 $ficheros
2359 ? sprintf(
2360 /* translators: %s: comma-separated list of files */
2361 __( 'Vigilant self-protection is switched off by code in: %s', 'vigilante' ),
2362 implode( ', ', $ficheros )
2363 )
2364 : __( 'Vigilant self-protection is switched off by code on this site', 'vigilante' ),
2365 array( 'files' => $ficheros ),
2366 'critical'
2367 );
2368 }
2369
2370 /**
2371 * Human label for a finding code.
2372 *
2373 * @param string $code Finding code.
2374 * @return string
2375 */
2376 public static function finding_label( $code ) {
2377 $labels = array(
2378 'self_modified' => __( 'Modified file', 'vigilante' ),
2379 'self_missing' => __( 'Missing file', 'vigilante' ),
2380 'self_extra' => __( 'Extra file', 'vigilante' ),
2381 'self_symlink' => __( 'Symbolic link', 'vigilante' ),
2382 'manifest_stale' => __( 'Manifest replaced (file is original)', 'vigilante' ),
2383 'manifest_replaced' => __( 'Manifest replaced', 'vigilante' ),
2384 'distribution_mismatch' => __( 'Differs from wp.org distribution', 'vigilante' ),
2385 'self_downgraded' => __( 'Version downgraded', 'vigilante' ),
2386 'manifest_missing' => __( 'Manifest missing', 'vigilante' ),
2387 'manifest_invalid' => __( 'Manifest not valid', 'vigilante' ),
2388 'manifest_unverified' => __( 'Manifest changed, unverifiable against wp.org', 'vigilante' ),
2389 'no_anchors' => __( 'No verification anchors available', 'vigilante' ),
2390 'cron_cleared_repeatedly' => __( 'Cron event repeatedly cleared', 'vigilante' ),
2391 'self_disabled' => __( 'Self-protection switched off by code', 'vigilante' ),
2392 'self_hooks_removed' => __( 'Self-protection hooks removed', 'vigilante' ),
2393 'self_stale' => __( 'Not checked recently', 'vigilante' ),
2394 );
2395 return isset( $labels[ $code ] ) ? $labels[ $code ] : $code;
2396 }
2397
2398 // -------------------------------------------------------------------
2399 // Internals
2400 // -------------------------------------------------------------------
2401
2402 /**
2403 * Build a finding entry.
2404 *
2405 * @param string $code Finding code (matrix row).
2406 * @param string $file Path relative to the plugin root (or hook name).
2407 * @param string $severity info|warning|critical.
2408 * @param string $message Human explanation.
2409 * @param string $variant Optional variant of the same code, so the guidance
2410 * catalogue can tell apart cases that share it (an
2411 * unreadable file from a modified one, an injected
2412 * executable from a stray log file). Stored only when
2413 * set, and never part of the dedupe fingerprint.
2414 * @return array
2415 */
2416 private function finding( $code, $file, $severity, $message, $variant = '' ) {
2417 $finding = array(
2418 'code' => $code,
2419 'file' => $file,
2420 'severity' => $severity,
2421 'message' => $message,
2422 );
2423 if ( '' !== $variant ) {
2424 $finding['variant'] = $variant;
2425 }
2426 return $finding;
2427 }
2428
2429 /**
2430 * Worst severity present in a finding set.
2431 *
2432 * @param array $findings Findings.
2433 * @return string critical|warning|info|none
2434 */
2435 private function worst_severity( $findings ) {
2436 $worst = 'none';
2437 foreach ( $findings as $finding ) {
2438 if ( 'critical' === $finding['severity'] ) {
2439 return 'critical';
2440 }
2441 if ( 'warning' === $finding['severity'] ) {
2442 $worst = 'warning';
2443 } elseif ( 'info' === $finding['severity'] && 'none' === $worst ) {
2444 $worst = 'info';
2445 }
2446 }
2447 return $worst;
2448 }
2449
2450 /**
2451 * Findings sorted worst first and cut to MAX_STORED_FINDINGS, so a
2452 * manifest crafted with thousands of entries cannot bloat the state.
2453 *
2454 * @param array $findings Findings.
2455 * @return array
2456 */
2457 private function cap_findings( $findings ) {
2458 if ( count( $findings ) <= self::MAX_STORED_FINDINGS ) {
2459 return $findings;
2460 }
2461 $rank = array(
2462 'critical' => 0,
2463 'warning' => 1,
2464 'info' => 2,
2465 );
2466 $keyed = array();
2467 foreach ( array_values( $findings ) as $index => $finding ) {
2468 $keyed[] = array( isset( $rank[ $finding['severity'] ] ) ? $rank[ $finding['severity'] ] : 3, $index, $finding );
2469 }
2470 usort(
2471 $keyed,
2472 function ( $a, $b ) {
2473 return ( $a[0] === $b[0] ) ? $a[1] - $b[1] : $a[0] - $b[0];
2474 }
2475 );
2476 return array_map(
2477 function ( $entry ) {
2478 return $entry[2];
2479 },
2480 array_slice( $keyed, 0, self::MAX_STORED_FINDINGS )
2481 );
2482 }
2483
2484 /**
2485 * Compact, stable summary of findings for logs and dedupe fingerprints.
2486 *
2487 * @param array $findings Findings.
2488 * @return array
2489 */
2490 private function findings_summary( $findings ) {
2491 $summary = array();
2492 foreach ( $this->cap_findings( $findings ) as $finding ) {
2493 $summary[] = $finding['code'] . ':' . $finding['file'];
2494 }
2495 sort( $summary, SORT_STRING );
2496 return $summary;
2497 }
2498
2499 /**
2500 * Log through the Activity Log when available.
2501 *
2502 * @param string $action Event action.
2503 * @param string $message Message.
2504 * @param array $data Extra data.
2505 * @param string $severity Severity.
2506 */
2507 private function log( $action, $message, $data = array(), $severity = 'info' ) {
2508 if ( ! $this->activity_log ) {
2509 return;
2510 }
2511 $this->activity_log->log( 'system', $action, $message, $data, $severity );
2512 }
2513 }
2514