PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-self-integrity.php

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

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