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