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