| 1 |
<?php |
| 2 |
/** |
| 3 |
* File Integrity Class |
| 4 |
* |
| 5 |
* Handles file integrity monitoring and scanning |
| 6 |
* |
| 7 |
* @package Vigilante |
| 8 |
*/ |
| 9 |
|
| 10 |
// Prevent direct access |
| 11 |
if ( ! defined( 'ABSPATH' ) ) { |
| 12 |
exit; |
| 13 |
} |
| 14 |
|
| 15 |
/** |
| 16 |
* Class Vigilante_File_Integrity |
| 17 |
* |
| 18 |
* Manages file integrity checks against WordPress.org checksums |
| 19 |
*/ |
| 20 |
class Vigilante_File_Integrity { |
| 21 |
|
| 22 |
/** |
| 23 |
* Settings instance |
| 24 |
* |
| 25 |
* @var Vigilante_Settings |
| 26 |
*/ |
| 27 |
private $settings; |
| 28 |
|
| 29 |
/** |
| 30 |
* Database instance |
| 31 |
* |
| 32 |
* @var Vigilante_Database |
| 33 |
*/ |
| 34 |
private $database; |
| 35 |
|
| 36 |
/** |
| 37 |
* Activity log instance |
| 38 |
* |
| 39 |
* @var Vigilante_Activity_Log |
| 40 |
*/ |
| 41 |
private $activity_log; |
| 42 |
|
| 43 |
/** |
| 44 |
* File integrity options |
| 45 |
* |
| 46 |
* @var array |
| 47 |
*/ |
| 48 |
private $options; |
| 49 |
|
| 50 |
/** |
| 51 |
* WordPress version |
| 52 |
* |
| 53 |
* @var string |
| 54 |
*/ |
| 55 |
private $wp_version; |
| 56 |
|
| 57 |
/** |
| 58 |
* Ignored files list |
| 59 |
* |
| 60 |
* @var array |
| 61 |
*/ |
| 62 |
private $ignored_files; |
| 63 |
|
| 64 |
/** |
| 65 |
* Scan start time for timeout control |
| 66 |
* |
| 67 |
* @var float |
| 68 |
*/ |
| 69 |
private $scan_start_time = 0; |
| 70 |
|
| 71 |
/** |
| 72 |
* Seconds a scan runs for at most |
| 73 |
* |
| 74 |
* A scan is one request, and it starts from the beginning every time. On |
| 75 |
* a site too large for this, what comes last is left unchecked on every |
| 76 |
* scan, so the results say what was not reached (see not_checked_lines()). |
| 77 |
*/ |
| 78 |
const MAX_SCAN_SECONDS = 60; |
| 79 |
|
| 80 |
/** |
| 81 |
* Maximum scan time in seconds (default 60s for thorough scanning) |
| 82 |
* |
| 83 |
* @var int |
| 84 |
*/ |
| 85 |
private $max_scan_time = self::MAX_SCAN_SECONDS; |
| 86 |
|
| 87 |
/** |
| 88 |
* Largest file the search for hidden code reads, in bytes |
| 89 |
* |
| 90 |
* It used to be 512,000, and a larger file was not opened at all: in a |
| 91 |
* plugin with no official copy that left it out of the results as if it |
| 92 |
* were clean. There is still a cap because the file is read whole, but |
| 93 |
* what is above it is reported as not searched instead of being passed |
| 94 |
* over (see search_file()). |
| 95 |
*/ |
| 96 |
const MAX_SEARCHED_BYTES = 8388608; |
| 97 |
|
| 98 |
/** |
| 99 |
* Seconds a scan gives to the folders of wp-content that belong to nothing |
| 100 |
* |
| 101 |
* @var int |
| 102 |
*/ |
| 103 |
private $content_folders_time = 10; |
| 104 |
|
| 105 |
/** |
| 106 |
* Whether an expression of the search for hidden code gave up on the file |
| 107 |
* being searched (see found()) |
| 108 |
* |
| 109 |
* @var bool |
| 110 |
*/ |
| 111 |
private $search_failed = false; |
| 112 |
|
| 113 |
/** |
| 114 |
* Option name for critical files baseline hashes. |
| 115 |
* |
| 116 |
* @var string |
| 117 |
*/ |
| 118 |
const BASELINE_OPTION = 'vigilante_critical_files_baseline'; |
| 119 |
|
| 120 |
/** |
| 121 |
* Option that records which version last redacted the stored baseline. |
| 122 |
* |
| 123 |
* @since 2.11.2 |
| 124 |
*/ |
| 125 |
const BASELINE_REDACTION_OPTION = 'vigilante_baseline_redaction'; |
| 126 |
|
| 127 |
/** |
| 128 |
* Network option recording that the one-off sweep of per-site baselines ran. |
| 129 |
* |
| 130 |
* @since 2.11.3 |
| 131 |
*/ |
| 132 |
const BASELINE_SWEEP_OPTION = 'vigilante_baseline_sweep'; |
| 133 |
|
| 134 |
/** |
| 135 |
* The migration the sweep marker stands for. |
| 136 |
* |
| 137 |
* A literal, not VIGILANTE_VERSION, and the difference is the whole point. |
| 138 |
* 2.11.3 stored the running version, so every release after it rearmed the |
| 139 |
* sweep: the first dashboard load on the main site walked the network with |
| 140 |
* switch_to_blog() to find nothing, because there was nothing left to find, |
| 141 |
* for ever, at the price of the one walk the marker exists to avoid. |
| 142 |
* |
| 143 |
* A plain boolean would fix that too, and would also leave no way to fire a |
| 144 |
* second network sweep the day another migration needs one. A literal costs |
| 145 |
* nothing today and keeps that door open. The redaction marker keeps the |
| 146 |
* running version on purpose: there the point IS to run again when the list |
| 147 |
* of what has to be redacted grows, and the cost of reopening it is one |
| 148 |
* option read rather than a walk of the network. |
| 149 |
* |
| 150 |
* And it must be a value NO version ever wrote into this option. The first |
| 151 |
* draft used '2.11.3', which is precisely what 2.11.3 wrote there, as its |
| 152 |
* own VIGILANTE_VERSION and BEFORE starting the walk: out in the wild that |
| 153 |
* value means "started, maybe unfinished". Reading it as "finished" left |
| 154 |
* every network whose 2.11.3 walk was cut short unswept for good, subsites |
| 155 |
* still holding the database password and the eight keys. Reproduced on the |
| 156 |
* Multisite install by a third cross review. The price of the new value is |
| 157 |
* that networks that did finish in 2.11.3 walk once more and find nothing. |
| 158 |
* |
| 159 |
* @since 2.11.4 |
| 160 |
*/ |
| 161 |
const BASELINE_SWEEP_MIGRATION = 'network-sweep-done'; |
| 162 |
|
| 163 |
/** |
| 164 |
* Network option with the fingerprint of every block Vigilant itself wrote |
| 165 |
* into wp-config.php or the root .htaccess. |
| 166 |
* |
| 167 |
* The integrity scan leaves Vigilant's own blocks out of the hash, so that |
| 168 |
* rewriting them is not reported as somebody else's change. Until 2.11.5 it |
| 169 |
* left out whatever sat between the markers, without looking. From 2.11.5 a |
| 170 |
* block is left out only if its content is exactly what Vigilant wrote, as |
| 171 |
* recorded here at write time. |
| 172 |
* |
| 173 |
* @since 2.11.5 |
| 174 |
*/ |
| 175 |
const OWNED_BLOCKS_OPTION = 'vigilante_owned_blocks'; |
| 176 |
|
| 177 |
/** |
| 178 |
* Network option marking that the blocks already on disk have been claimed. |
| 179 |
* |
| 180 |
* @since 2.11.5 |
| 181 |
*/ |
| 182 |
const OWNED_BLOCKS_CLAIM_OPTION = 'vigilante_owned_blocks_claim'; |
| 183 |
|
| 184 |
/** |
| 185 |
* Value stored when the claim is done. Deliberately not a version number: |
| 186 |
* that is the lesson of BASELINE_SWEEP_MIGRATION above. |
| 187 |
* |
| 188 |
* @since 2.11.5 |
| 189 |
*/ |
| 190 |
const OWNED_BLOCKS_CLAIMED = 'claimed'; |
| 191 |
|
| 192 |
/** |
| 193 |
* What replaces a secret value kept in the baseline. |
| 194 |
* |
| 195 |
* Fixed forever: if this string ever changes, every stored baseline |
| 196 |
* suddenly differs from the freshly redacted file and every site reports a |
| 197 |
* change to wp-config.php that never happened. |
| 198 |
* |
| 199 |
* @since 2.11.2 |
| 200 |
*/ |
| 201 |
const REDACTED_MARKER = '[redacted by Vigilant]'; |
| 202 |
|
| 203 |
/** |
| 204 |
* Constant names whose value is checked even where the file does not name them |
| 205 |
* |
| 206 |
* The eight WordPress keys and salts and the database credentials. Until |
| 207 |
* 2.11.7 this list, plus names that read like a credential, was what got |
| 208 |
* redacted, and a real wp-config.php collects secrets under any name: |
| 209 |
* FTP_PASS, SMTP passwords, cloud keys inside serialize( array( ... ) ), |
| 210 |
* any const. Since 2.11.8 every value is redacted and this list only feeds |
| 211 |
* the output check of baseline_content(). |
| 212 |
* |
| 213 |
* @since 2.11.2 |
| 214 |
* |
| 215 |
* @var string[] |
| 216 |
*/ |
| 217 |
private static $secret_constants = array( |
| 218 |
'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST', |
| 219 |
'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', |
| 220 |
'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT', |
| 221 |
); |
| 222 |
|
| 223 |
/** |
| 224 |
* Core constants whose value stays readable in the baseline copy |
| 225 |
* |
| 226 |
* Where the site lives, where its folders are, how much memory it gets: |
| 227 |
* none of it is a secret and all of it is what a diff of wp-config.php is |
| 228 |
* read for. Every other value is redacted. A list of what is secret can |
| 229 |
* never be complete, which is how 2.11.2 to 2.11.7 missed FTP_PASS; a list |
| 230 |
* of what is not can be short and still be right. |
| 231 |
* |
| 232 |
* @since 2.11.8 |
| 233 |
* |
| 234 |
* @var string[] |
| 235 |
*/ |
| 236 |
private static $readable_constants = array( |
| 237 |
'ABSPATH', 'WPINC', 'WP_HOME', 'WP_SITEURL', 'WP_CONTENT_DIR', 'WP_CONTENT_URL', |
| 238 |
'WP_PLUGIN_DIR', 'WP_PLUGIN_URL', 'WPMU_PLUGIN_DIR', 'WPMU_PLUGIN_URL', 'UPLOADS', |
| 239 |
'WP_LANG_DIR', 'WP_TEMP_DIR', 'WP_DEBUG_LOG', 'WP_MEMORY_LIMIT', 'WP_MAX_MEMORY_LIMIT', |
| 240 |
'WP_ENVIRONMENT_TYPE', 'WP_DEVELOPMENT_MODE', 'WP_AUTO_UPDATE_CORE', 'FS_METHOD', |
| 241 |
'DB_CHARSET', 'DB_COLLATE', 'DOMAIN_CURRENT_SITE', 'PATH_CURRENT_SITE', 'NOBLOGREDIRECT', |
| 242 |
'COOKIE_DOMAIN', 'COOKIEPATH', 'SITECOOKIEPATH', 'ADMIN_COOKIE_PATH', 'PLUGINS_COOKIE_PATH', |
| 243 |
'WP_DEFAULT_THEME', 'WPLANG', |
| 244 |
// Numeric core settings. Since 2.11.10 a number in the value of a |
| 245 |
// define() is redacted like any other value, so the ones that are known |
| 246 |
// not to be credentials are listed here to keep the diff useful. |
| 247 |
'AUTOSAVE_INTERVAL', 'WP_POST_REVISIONS', 'EMPTY_TRASH_DAYS', 'WP_CRON_LOCK_TIMEOUT', |
| 248 |
'FS_CHMOD_DIR', 'FS_CHMOD_FILE', 'SITE_ID_CURRENT_SITE', 'BLOG_ID_CURRENT_SITE', |
| 249 |
); |
| 250 |
|
| 251 |
/** |
| 252 |
* Read the critical files baseline, from where it belongs |
| 253 |
* |
| 254 |
* Both watched files, wp-config.php and the root .htaccess, belong to the |
| 255 |
* whole network: there is one of each per installation, not one per site. |
| 256 |
* Keeping the baseline in a per-site option meant every site of a network |
| 257 |
* stored its own copy of the same wp-config.php, so a network of fifty |
| 258 |
* sites held fifty copies of the same credentials, and a cleanup that ran |
| 259 |
* on one site left the other forty nine untouched. Reported by @calzbert on |
| 260 |
* 10 sep 2026 and reproduced on the Multisite install. Since 2.11.3 there |
| 261 |
* is one baseline per network. |
| 262 |
* |
| 263 |
* No is_multisite() branch on purpose, and this is worth reading before |
| 264 |
* anyone adds one back: the core functions already make the distinction. |
| 265 |
* Verified in the installed core, wp-includes/option.php, where |
| 266 |
* get_network_option() falls back to get_option() on a single site and |
| 267 |
* update_network_option() falls back to update_option( $option, $value, |
| 268 |
* false ), autoload already off, which is exactly what this needs. Two |
| 269 |
* branches doing the same thing are two branches that can drift apart, |
| 270 |
* and one of them did during this very change. |
| 271 |
* |
| 272 |
* @since 2.11.3 |
| 273 |
* |
| 274 |
* @return array |
| 275 |
*/ |
| 276 |
private function read_baseline() { |
| 277 |
$baseline = get_site_option( self::BASELINE_OPTION, array() ); |
| 278 |
|
| 279 |
return is_array( $baseline ) ? $baseline : array(); |
| 280 |
} |
| 281 |
|
| 282 |
/** |
| 283 |
* Store the critical files baseline where read_baseline() looks for it |
| 284 |
* |
| 285 |
* @since 2.11.3 |
| 286 |
* |
| 287 |
* @param array $baseline Baseline to store. |
| 288 |
* @return bool |
| 289 |
*/ |
| 290 |
private function write_baseline( $baseline ) { |
| 291 |
return update_site_option( self::BASELINE_OPTION, $baseline ); |
| 292 |
} |
| 293 |
|
| 294 |
/** |
| 295 |
* Fingerprint of a block exactly as the integrity scan reads it back |
| 296 |
* |
| 297 |
* @since 2.11.5 |
| 298 |
* |
| 299 |
* @param string $block Block from start marker to end marker, inclusive. |
| 300 |
* @return string |
| 301 |
*/ |
| 302 |
private static function block_fingerprint( $block ) { |
| 303 |
return md5( str_replace( array( "\r\n", "\r" ), "\n", (string) $block ) ); |
| 304 |
} |
| 305 |
|
| 306 |
/** |
| 307 |
* Record a block Vigilant has just written |
| 308 |
* |
| 309 |
* Called by the writers right after a verified write, so the scan can tell |
| 310 |
* Vigilant's block from anything else carrying the same markers. By default |
| 311 |
* it replaces the earlier record for that marker: after a write, only the |
| 312 |
* block just written is Vigilant's. |
| 313 |
* |
| 314 |
* @since 2.11.5 |
| 315 |
* |
| 316 |
* @param string $filename 'wp-config.php' or '.htaccess'. |
| 317 |
* @param string $marker_start Start marker of the block. |
| 318 |
* @param string $block Block from start marker to end marker, inclusive. |
| 319 |
* @param bool $replace Drop earlier records for the same marker first. |
| 320 |
* @return bool |
| 321 |
*/ |
| 322 |
public static function remember_owned_block( $filename, $marker_start, $block, $replace = true ) { |
| 323 |
$owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() ); |
| 324 |
$owned = is_array( $owned ) ? $owned : array(); |
| 325 |
$file = ( isset( $owned[ $filename ] ) && is_array( $owned[ $filename ] ) ) ? $owned[ $filename ] : array(); |
| 326 |
|
| 327 |
if ( $replace ) { |
| 328 |
foreach ( $file as $fingerprint => $marker ) { |
| 329 |
if ( $marker === $marker_start ) { |
| 330 |
unset( $file[ $fingerprint ] ); |
| 331 |
} |
| 332 |
} |
| 333 |
} |
| 334 |
|
| 335 |
$file[ self::block_fingerprint( $block ) ] = $marker_start; |
| 336 |
$owned[ $filename ] = $file; |
| 337 |
|
| 338 |
return update_site_option( self::OWNED_BLOCKS_OPTION, $owned ); |
| 339 |
} |
| 340 |
|
| 341 |
/** |
| 342 |
* Forget the blocks recorded for a marker, once Vigilant has removed them |
| 343 |
* |
| 344 |
* @since 2.11.5 |
| 345 |
* |
| 346 |
* @param string $filename 'wp-config.php' or '.htaccess'. |
| 347 |
* @param string $marker_start Start marker of the block. |
| 348 |
* @return bool |
| 349 |
*/ |
| 350 |
public static function forget_owned_blocks( $filename, $marker_start ) { |
| 351 |
$owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() ); |
| 352 |
|
| 353 |
if ( ! is_array( $owned ) || empty( $owned[ $filename ] ) || ! is_array( $owned[ $filename ] ) ) { |
| 354 |
return true; |
| 355 |
} |
| 356 |
|
| 357 |
$changed = false; |
| 358 |
|
| 359 |
foreach ( $owned[ $filename ] as $fingerprint => $marker ) { |
| 360 |
if ( $marker === $marker_start ) { |
| 361 |
unset( $owned[ $filename ][ $fingerprint ] ); |
| 362 |
$changed = true; |
| 363 |
} |
| 364 |
} |
| 365 |
|
| 366 |
return $changed ? update_site_option( self::OWNED_BLOCKS_OPTION, $owned ) : true; |
| 367 |
} |
| 368 |
|
| 369 |
/** |
| 370 |
* Whether a block is one Vigilant wrote |
| 371 |
* |
| 372 |
* @since 2.11.5 |
| 373 |
* |
| 374 |
* @param string $filename 'wp-config.php' or '.htaccess'. |
| 375 |
* @param string $block Block from start marker to end marker, inclusive. |
| 376 |
* @return bool |
| 377 |
*/ |
| 378 |
private static function is_owned_block( $filename, $block ) { |
| 379 |
$owned = get_site_option( self::OWNED_BLOCKS_OPTION, array() ); |
| 380 |
|
| 381 |
return is_array( $owned ) |
| 382 |
&& isset( $owned[ $filename ] ) |
| 383 |
&& is_array( $owned[ $filename ] ) |
| 384 |
&& isset( $owned[ $filename ][ self::block_fingerprint( $block ) ] ); |
| 385 |
} |
| 386 |
|
| 387 |
/** |
| 388 |
* Whether the blocks already on disk have been claimed |
| 389 |
* |
| 390 |
* @since 2.11.5 |
| 391 |
* |
| 392 |
* @return bool |
| 393 |
*/ |
| 394 |
private function owned_blocks_claimed() { |
| 395 |
return self::OWNED_BLOCKS_CLAIMED === get_site_option( self::OWNED_BLOCKS_CLAIM_OPTION ); |
| 396 |
} |
| 397 |
|
| 398 |
/** |
| 399 |
* The baseline copy of a critical file, with no secret in it |
| 400 |
* |
| 401 |
* The integrity scan keeps a copy of wp-config.php so it can show which |
| 402 |
* lines changed. Until 2.11.1 that copy was the file itself minus the |
| 403 |
* plugin's own blocks, so the options table held the database password and |
| 404 |
* the eight authentication keys and salts, and anybody who later read the |
| 405 |
* database or a backup of it got them without ever touching the |
| 406 |
* filesystem. Reported by the automated security review of wp.org on 9 sep |
| 407 |
* 2026 and fixed in 2.11.2. |
| 408 |
* |
| 409 |
* The hash is still taken over the whole file, so a change to a secret is |
| 410 |
* still detected; what changes is that the diff cannot show it, which is |
| 411 |
* the right trade. |
| 412 |
* |
| 413 |
* @since 2.11.2 |
| 414 |
* |
| 415 |
* @param string $filename Critical file name. |
| 416 |
* @param string $normalized Normalized content. |
| 417 |
* @return string Content safe to store, or '' when it cannot be made safe. |
| 418 |
*/ |
| 419 |
private function baseline_content( $filename, $normalized ) { |
| 420 |
if ( '.htaccess' === $filename ) { |
| 421 |
return $this->redact_server_secrets( $normalized ); |
| 422 |
} |
| 423 |
|
| 424 |
if ( 'wp-config.php' !== $filename ) { |
| 425 |
return $normalized; |
| 426 |
} |
| 427 |
|
| 428 |
// Calculados una sola vez: los usa la redaccion (para no conservar un |
| 429 |
// numero que ademas este en vigor) y el control de salida de abajo. |
| 430 |
$live_values = $this->values_in_force( $normalized ); |
| 431 |
$redacted = $this->redact_secrets( $normalized, $live_values ); |
| 432 |
|
| 433 |
/* |
| 434 |
* Belt and braces, and this is the part that matters: the redaction |
| 435 |
* above is the thing most likely to miss a shape nobody thought of, |
| 436 |
* and the cost of missing one is a secret in the database. So the |
| 437 |
* result is checked against the values actually in force, and if any |
| 438 |
* of them survived, nothing is stored at all. The scan then reports the |
| 439 |
* change without a line diff, which the interface already handles, |
| 440 |
* instead of leaking. |
| 441 |
* |
| 442 |
* Until 2.11.7 the check covered the twelve constants of WordPress and |
| 443 |
* nothing else, so a value the regular expression missed went straight |
| 444 |
* through it. It now covers every constant the file names and every |
| 445 |
* environment variable it reads. |
| 446 |
* |
| 447 |
* It runs against the copy that is actually stored. A value in force |
| 448 |
* that sits inside the value of a readable constant, such as a Redis |
| 449 |
* prefix equal to the domain inside WP_HOME, is not a secret left |
| 450 |
* behind, so that one alone is not looked for; without that, every such |
| 451 |
* site would lose its diff. The first version of this checked a |
| 452 |
* stricter copy instead, and a secret inside a kept include path went |
| 453 |
* straight past it (cross review of 2.11.8). |
| 454 |
* |
| 455 |
* Only values of eight characters or more are checked: DB_NAME is |
| 456 |
* often something like "local" or "wp", and looking for that inside a |
| 457 |
* PHP file matches by accident every time. |
| 458 |
*/ |
| 459 |
if ( '' === $redacted ) { |
| 460 |
return ''; |
| 461 |
} |
| 462 |
|
| 463 |
$shown = $this->readable_values_in_force(); |
| 464 |
|
| 465 |
foreach ( $live_values as $value ) { |
| 466 |
foreach ( $shown as $readable ) { |
| 467 |
if ( false !== strpos( $readable, $value ) ) { |
| 468 |
continue 2; |
| 469 |
} |
| 470 |
} |
| 471 |
|
| 472 |
if ( false !== strpos( $redacted, $value ) ) { |
| 473 |
return ''; |
| 474 |
} |
| 475 |
} |
| 476 |
|
| 477 |
return $redacted; |
| 478 |
} |
| 479 |
|
| 480 |
/** |
| 481 |
* Replace every value in wp-config.php with a marker |
| 482 |
* |
| 483 |
* Reads the file as PHP tokens and replaces every string in it: quoted, |
| 484 |
* with variables inside, heredoc and nowdoc. What stays is what names a |
| 485 |
* thing rather than holding it: the name passed to define(), defined(), |
| 486 |
* constant() and getenv(), the name in putenv( 'NAME=value' ), array keys, |
| 487 |
* an index such as $_ENV['NAME'], and strings of a single character that |
| 488 |
* are not the value of a define(). Also the value of the constants in |
| 489 |
* $readable_constants, the table prefix and a path passed to require or |
| 490 |
* include, which are not secrets and are what a diff of this file is read |
| 491 |
* for. A path is kept only while it looks like one, and only up to where |
| 492 |
* its expression ends. |
| 493 |
* |
| 494 |
* Until 2.11.7 this was a regular expression over define() with a list of |
| 495 |
* names, and it missed FTP_PASS, SMTP passwords, cloud keys inside |
| 496 |
* serialize( array( ... ) ), every const and every value read with a |
| 497 |
* fallback. Measured while preparing 2.11.8: 10 of 15 real shapes stored |
| 498 |
* their secret. |
| 499 |
* |
| 500 |
* The marker always goes in single quotes, whatever the original used, so |
| 501 |
* a copy redacted by an earlier version and the same file redacted today |
| 502 |
* read the same line for line. |
| 503 |
* |
| 504 |
* @since 2.11.2 |
| 505 |
* @since 2.11.8 Reads tokens and redacts every value. |
| 506 |
* |
| 507 |
* @param string $content Normalized wp-config.php content. |
| 508 |
* @return string Redacted content, or '' when it cannot be read as tokens. |
| 509 |
*/ |
| 510 |
private function redact_secrets( $content, $live_values = array() ) { |
| 511 |
if ( ! function_exists( 'token_get_all' ) ) { |
| 512 |
return ''; |
| 513 |
} |
| 514 |
|
| 515 |
$marker = "'" . self::REDACTED_MARKER . "'"; |
| 516 |
$tokens = self::merged_tokens( token_get_all( (string) $content ) ); |
| 517 |
$count = count( $tokens ); |
| 518 |
$names = defined( 'T_NAME_FULLY_QUALIFIED' ) ? array( T_STRING, T_NAME_FULLY_QUALIFIED ) : array( T_STRING ); |
| 519 |
$includes = array( T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE ); |
| 520 |
$out = ''; |
| 521 |
$depth = 0; |
| 522 |
$keep_until = -1; |
| 523 |
$define_at = -1; |
| 524 |
$in_include = false; |
| 525 |
$include_depth = 0; |
| 526 |
$include_ends = array( T_CLOSE_TAG, T_BOOLEAN_OR, T_BOOLEAN_AND, T_LOGICAL_OR, T_LOGICAL_AND, T_COALESCE ); |
| 527 |
|
| 528 |
for ( $i = 0; $i < $count; $i++ ) { |
| 529 |
list( $type, $text, $plain ) = $tokens[ $i ]; |
| 530 |
|
| 531 |
if ( '(' === $type ) { |
| 532 |
$depth++; |
| 533 |
} elseif ( ')' === $type ) { |
| 534 |
// The closing parenthesis of a readable define(), or of any define(). |
| 535 |
if ( $depth === $keep_until ) { |
| 536 |
$keep_until = -1; |
| 537 |
} |
| 538 |
if ( $depth === $define_at ) { |
| 539 |
$define_at = -1; |
| 540 |
} |
| 541 |
$depth--; |
| 542 |
|
| 543 |
// A parenthesis that closes around the include ends its path. |
| 544 |
if ( $in_include && $depth < $include_depth ) { |
| 545 |
$in_include = false; |
| 546 |
} |
| 547 |
} elseif ( in_array( $type, $includes, true ) ) { |
| 548 |
$in_include = true; |
| 549 |
$include_depth = $depth; |
| 550 |
} elseif ( $in_include && ( in_array( $type, array( ';', '{', '}', '?', ':', ',' ), true ) || in_array( $type, $include_ends, true ) ) ) { |
| 551 |
/* |
| 552 |
* The path of an include ends where its expression does. The |
| 553 |
* first version of this only ended it at ';', so the value in |
| 554 |
* `( include 'db.php' ) || define( 'FTP_PASS', '...' )`, in a |
| 555 |
* ternary after require, or after a closing tag, was kept. |
| 556 |
* Found by the cross review of 2.11.8. |
| 557 |
*/ |
| 558 |
$in_include = false; |
| 559 |
} |
| 560 |
|
| 561 |
if ( T_COMMENT === $type || T_DOC_COMMENT === $type ) { |
| 562 |
$out .= $this->redact_comment( $text ); |
| 563 |
continue; |
| 564 |
} |
| 565 |
|
| 566 |
if ( T_INLINE_HTML === $type ) { |
| 567 |
$out .= ( '' === trim( $text ) ) ? $text : $marker; |
| 568 |
continue; |
| 569 |
} |
| 570 |
|
| 571 |
/* |
| 572 |
* A value that is not a quoted string is still a value. Until |
| 573 |
* 2.11.10 only string tokens were looked at, so |
| 574 |
* define( 'SERVICE_TOKEN', 12345678 ) put the live token in the copy |
| 575 |
* kept in the database. Reported by the wp.org automated review of |
| 576 |
* 2.11.9. |
| 577 |
* |
| 578 |
* The first fix here redacted a number only in the value position of |
| 579 |
* a define(), which is the shape that was reported and not the shape |
| 580 |
* of the problem. The second cross review of 2.11.10 measured nine |
| 581 |
* more: a negative number, one in parentheses, one inside |
| 582 |
* array( ... ), one in a ternary, a const, and four that are not |
| 583 |
* constants at all and so the output check cannot catch either, the |
| 584 |
* worst of them the documented way of configuring Redis, |
| 585 |
* $redis_server = array( 'auth' => 12345678 ). So a number is now |
| 586 |
* treated like a string: redacted unless the place it sits in is one |
| 587 |
* of the few that cannot hold a credential, which is how the rest of |
| 588 |
* this function has been written since 2.11.8 (a list of what may be |
| 589 |
* shown, never a list of what is secret). |
| 590 |
* |
| 591 |
* Losing a number from the diff costs little and buys the same trade |
| 592 |
* as everywhere else: the hash still covers the whole file, so a |
| 593 |
* change is detected even where the diff can no longer show it. The |
| 594 |
* numeric core settings are in $readable_constants so the diff of a |
| 595 |
* normal wp-config.php keeps saying what it used to. |
| 596 |
*/ |
| 597 |
if ( T_LNUMBER === $type || T_DNUMBER === $type ) { |
| 598 |
$nprev = self::significant_token( $tokens, $i, -1 ); |
| 599 |
$nnext = self::significant_token( $tokens, $i, 1 ); |
| 600 |
$nptype = ( null === $nprev ) ? null : $tokens[ $nprev ][0]; |
| 601 |
$nntype = ( null === $nnext ) ? null : $tokens[ $nnext ][0]; |
| 602 |
$nbefore = ( '[' === $nptype ) ? self::significant_token( $tokens, $nprev, -1 ) : null; |
| 603 |
$nbtoken = ( null === $nbefore ) ? array( null, null ) : $tokens[ $nbefore ]; |
| 604 |
$nvalue = ( -1 !== $define_at && $depth === $define_at && ',' === $nptype ); |
| 605 |
|
| 606 |
$nkeep = $keep_until >= 0 |
| 607 |
|| T_DOUBLE_ARROW === $nntype |
| 608 |
|| ( '[' === $nptype && ']' === $nntype && in_array( $nbtoken[0], array( T_VARIABLE, T_STRING, ']', ')', '}' ), true ) ) |
| 609 |
|| ( strlen( $text ) <= 1 && ! $nvalue ); |
| 610 |
|
| 611 |
/* |
| 612 |
* Except when that same number is a value actually in force. The |
| 613 |
* positions kept above are kept because a credential does not live |
| 614 |
* in them, which is true, but it says nothing about the number |
| 615 |
* itself: with |
| 616 |
* define( 'SERVICE_TOKEN', 12345678 ); |
| 617 |
* $a = $config[12345678]; |
| 618 |
* the value was redacted in the define and kept in the index, so it |
| 619 |
* survived, and the output check below did what it is there for and |
| 620 |
* threw the whole copy away. No leak, but the diff of that |
| 621 |
* wp-config.php was lost for good, which is the regression 2.11.8 |
| 622 |
* fixed, coming back through the numbers added in 2.11.10. Found by |
| 623 |
* the third cross review. |
| 624 |
* |
| 625 |
* Strings are deliberately NOT treated this way: there, a value in |
| 626 |
* force sitting in a kept position (an include path, an array key) |
| 627 |
* can BE the secret, and losing the diff is the right answer. It is |
| 628 |
* what poc/wpconfig-baseline-secretos.sh checks and it stays. |
| 629 |
*/ |
| 630 |
if ( $nkeep && in_array( $text, $live_values, true ) ) { |
| 631 |
$nkeep = false; |
| 632 |
} |
| 633 |
|
| 634 |
$out .= $nkeep ? $text : $marker; |
| 635 |
continue; |
| 636 |
} |
| 637 |
|
| 638 |
if ( 'string' !== $type ) { |
| 639 |
$out .= $text; |
| 640 |
continue; |
| 641 |
} |
| 642 |
|
| 643 |
$prev = self::significant_token( $tokens, $i, -1 ); |
| 644 |
$next = self::significant_token( $tokens, $i, 1 ); |
| 645 |
$ptype = ( null === $prev ) ? null : $tokens[ $prev ][0]; |
| 646 |
$ntype = ( null === $next ) ? null : $tokens[ $next ][0]; |
| 647 |
$call = ( '(' === $ptype ) ? self::significant_token( $tokens, $prev, -1 ) : null; |
| 648 |
$inner = $plain ? substr( $text, 1, -1 ) : null; |
| 649 |
|
| 650 |
if ( $plain && null !== $call && in_array( $tokens[ $call ][0], $names, true ) ) { |
| 651 |
$function = strtolower( ltrim( $tokens[ $call ][1], '\\' ) ); |
| 652 |
|
| 653 |
if ( in_array( $function, array( 'define', 'defined', 'constant', 'getenv' ), true ) ) { |
| 654 |
if ( 'define' === $function ) { |
| 655 |
$define_at = $depth; |
| 656 |
|
| 657 |
if ( in_array( $inner, self::$readable_constants, true ) ) { |
| 658 |
$keep_until = $depth; |
| 659 |
} |
| 660 |
} |
| 661 |
$out .= $text; |
| 662 |
continue; |
| 663 |
} |
| 664 |
|
| 665 |
if ( 'putenv' === $function && false !== strpos( $inner, '=' ) ) { |
| 666 |
$out .= "'" . substr( $inner, 0, strpos( $inner, '=' ) + 1 ) . self::REDACTED_MARKER . "'"; |
| 667 |
continue; |
| 668 |
} |
| 669 |
} |
| 670 |
|
| 671 |
// The token before an opening bracket or an assignment, when there is one. |
| 672 |
$before = ( '[' === $ptype || '=' === $ptype ) ? self::significant_token( $tokens, $prev, -1 ) : null; |
| 673 |
$btoken = ( null === $before ) ? array( null, null ) : $tokens[ $before ]; |
| 674 |
|
| 675 |
/* |
| 676 |
* The value of a define() is redacted whatever its length, as it was |
| 677 |
* up to 2.11.7, so an empty password reads the same in a copy stored |
| 678 |
* then as in today's; the first version of this kept strings of one |
| 679 |
* character there and a file awaiting review showed credential lines |
| 680 |
* nobody had touched (cross review of 2.11.8). |
| 681 |
*/ |
| 682 |
$is_define_value = ( -1 !== $define_at && $depth === $define_at && ',' === $ptype ); |
| 683 |
$is_path = $in_include && $plain |
| 684 |
&& preg_match( '#^[A-Za-z0-9_./\-]+$#', (string) $inner ) |
| 685 |
&& ( false !== strpos( (string) $inner, '/' ) || '.php' === substr( (string) $inner, -4 ) ); |
| 686 |
|
| 687 |
$keep = ( $plain && strlen( $inner ) <= 1 && ! $is_define_value ) |
| 688 |
|| T_DOUBLE_ARROW === $ntype |
| 689 |
|| ( '[' === $ptype && ']' === $ntype && in_array( $btoken[0], array( T_VARIABLE, T_STRING, ']', ')', '}' ), true ) ) |
| 690 |
|| $keep_until >= 0 |
| 691 |
|| $is_path |
| 692 |
|| ( '=' === $ptype && ';' === $ntype && T_VARIABLE === $btoken[0] && '$table_prefix' === $btoken[1] ); |
| 693 |
|
| 694 |
$out .= $keep ? $text : $marker; |
| 695 |
} |
| 696 |
|
| 697 |
return $out; |
| 698 |
} |
| 699 |
|
| 700 |
/** |
| 701 |
* PHP tokens with every string folded into a single token |
| 702 |
* |
| 703 |
* The tokenizer splits a string with variables inside, a heredoc and a |
| 704 |
* backtick command into several tokens. For the redaction each of them is |
| 705 |
* one value, so they come back as a single token of type 'string'. The |
| 706 |
* third field says whether it is a plain quoted literal. |
| 707 |
* |
| 708 |
* @since 2.11.8 |
| 709 |
* |
| 710 |
* @param array $raw Output of token_get_all(). |
| 711 |
* @return array List of array( type, text, plain ). |
| 712 |
*/ |
| 713 |
private static function merged_tokens( $raw ) { |
| 714 |
$tokens = array(); |
| 715 |
$count = count( $raw ); |
| 716 |
|
| 717 |
for ( $i = 0; $i < $count; $i++ ) { |
| 718 |
$token = $raw[ $i ]; |
| 719 |
|
| 720 |
if ( '"' === $token || '`' === $token ) { |
| 721 |
$text = $token; |
| 722 |
for ( $i++; $i < $count; $i++ ) { |
| 723 |
$text .= is_array( $raw[ $i ] ) ? $raw[ $i ][1] : $raw[ $i ]; |
| 724 |
if ( $raw[ $i ] === $token ) { |
| 725 |
break; |
| 726 |
} |
| 727 |
} |
| 728 |
$tokens[] = array( 'string', $text, false ); |
| 729 |
continue; |
| 730 |
} |
| 731 |
|
| 732 |
if ( is_array( $token ) && T_START_HEREDOC === $token[0] ) { |
| 733 |
$text = $token[1]; |
| 734 |
for ( $i++; $i < $count; $i++ ) { |
| 735 |
$text .= is_array( $raw[ $i ] ) ? $raw[ $i ][1] : $raw[ $i ]; |
| 736 |
if ( is_array( $raw[ $i ] ) && T_END_HEREDOC === $raw[ $i ][0] ) { |
| 737 |
break; |
| 738 |
} |
| 739 |
} |
| 740 |
$tokens[] = array( 'string', $text, false ); |
| 741 |
continue; |
| 742 |
} |
| 743 |
|
| 744 |
// An unterminated string comes back as T_ENCAPSED_AND_WHITESPACE |
| 745 |
// on its own, and it is a value like any other. |
| 746 |
if ( is_array( $token ) && ( T_CONSTANT_ENCAPSED_STRING === $token[0] || T_ENCAPSED_AND_WHITESPACE === $token[0] ) ) { |
| 747 |
$tokens[] = array( 'string', $token[1], T_CONSTANT_ENCAPSED_STRING === $token[0] ); |
| 748 |
continue; |
| 749 |
} |
| 750 |
|
| 751 |
$tokens[] = is_array( $token ) ? array( $token[0], $token[1], false ) : array( $token, $token, false ); |
| 752 |
} |
| 753 |
|
| 754 |
return $tokens; |
| 755 |
} |
| 756 |
|
| 757 |
/** |
| 758 |
* Index of the nearest token that is not whitespace or a comment |
| 759 |
* |
| 760 |
* @since 2.11.8 |
| 761 |
* |
| 762 |
* @param array $tokens Output of merged_tokens(). |
| 763 |
* @param int $from Index to start from, not included. |
| 764 |
* @param int $step -1 to look back, 1 to look ahead. |
| 765 |
* @return int|null |
| 766 |
*/ |
| 767 |
private static function significant_token( $tokens, $from, $step ) { |
| 768 |
$count = count( $tokens ); |
| 769 |
|
| 770 |
for ( $i = $from + $step; $i >= 0 && $i < $count; $i += $step ) { |
| 771 |
if ( ! in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { |
| 772 |
return $i; |
| 773 |
} |
| 774 |
} |
| 775 |
|
| 776 |
return null; |
| 777 |
} |
| 778 |
|
| 779 |
/** |
| 780 |
* Redact a comment, keeping its plain words |
| 781 |
* |
| 782 |
* To the tokenizer a comment is text, and wp-config.php files keep old |
| 783 |
* credentials in them, commented out or in a note. The first version of |
| 784 |
* this, in the same release, redacted what was between quotes: an |
| 785 |
* apostrophe in prose ("Don't use 'the-old-password'") paired with the |
| 786 |
* opening quote of the secret and left it out, and a secret without quotes |
| 787 |
* was never touched. Found by the cross review of 2.11.8. |
| 788 |
* |
| 789 |
* So it works the other way round. A comment keeps its plain words |
| 790 |
* (lowercase, capitalised or uppercase letters, or two capitalised parts |
| 791 |
* such as WordPress, and docblock tags), constant names, and anything |
| 792 |
* shorter than eight characters; every other run of characters, a URL, a |
| 793 |
* key, a password with a digit in it, becomes the marker. The value of a |
| 794 |
* commented-out define() goes in single quotes whatever its length, as in |
| 795 |
* code and as 2.11.2 to 2.11.7 wrote it, so a copy stored by those |
| 796 |
* versions reads the same line for line. What this cannot tell from prose |
| 797 |
* is a password made only of plain letters; the output check still |
| 798 |
* catches it when it is a value in force. |
| 799 |
* |
| 800 |
* @since 2.11.8 |
| 801 |
* |
| 802 |
* @param string $comment Comment token text. |
| 803 |
* @return string |
| 804 |
*/ |
| 805 |
private function redact_comment( $comment ) { |
| 806 |
$marker = self::REDACTED_MARKER; |
| 807 |
$readable = self::$readable_constants; |
| 808 |
|
| 809 |
// Only the text between the delimiters is redacted: "/**#@-*/" in |
| 810 |
// wp-config-sample.php is a single run of eight characters, and |
| 811 |
// replacing it whole took the comment markers with it. |
| 812 |
if ( ! preg_match( '#\A(/\*\*?|//|\#)(.*?)(\*/)?\z#s', $comment, $parts ) ) { |
| 813 |
$parts = array( $comment, '', $comment ); |
| 814 |
} |
| 815 |
|
| 816 |
$open = $parts[1]; |
| 817 |
$close = isset( $parts[3] ) ? $parts[3] : ''; |
| 818 |
$comment = preg_replace_callback( |
| 819 |
'/(\bdefine\s*\(\s*([\'"])((?:\\\\.|(?!\2).)*)\2\s*,\s*)([\'"])((?:\\\\.|(?!\4).)*)\4/i', |
| 820 |
function ( $match ) use ( $marker, $readable ) { |
| 821 |
return in_array( $match[3], $readable, true ) ? $match[0] : $match[1] . "'" . $marker . "'"; |
| 822 |
}, |
| 823 |
$parts[2] |
| 824 |
); |
| 825 |
|
| 826 |
if ( null === $comment ) { |
| 827 |
return ''; |
| 828 |
} |
| 829 |
|
| 830 |
$redacted = preg_replace_callback( |
| 831 |
'/[^\s\'"`(),;\[\]{}<>=]+/u', |
| 832 |
function ( $match ) use ( $marker ) { |
| 833 |
$word = $match[0]; |
| 834 |
$core = rtrim( $word, '.:!?' ); |
| 835 |
|
| 836 |
// Plain words only, without hyphens: a passphrase written as |
| 837 |
// lowercase words joined by hyphens reads as prose otherwise, and |
| 838 |
// the PoC of this very fix caught one surviving. |
| 839 |
if ( strlen( $core ) < 8 |
| 840 |
|| preg_match( '/^@?(?:\p{Lu}?\p{Ll}+|\p{Lu}+)$/u', $core ) |
| 841 |
|| preg_match( '/^\p{Lu}\p{Ll}+\p{Lu}\p{Ll}+$/u', $core ) |
| 842 |
|| preg_match( '/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/', $core ) |
| 843 |
) { |
| 844 |
return $word; |
| 845 |
} |
| 846 |
|
| 847 |
return $marker . substr( $word, strlen( $core ) ); |
| 848 |
}, |
| 849 |
$comment |
| 850 |
); |
| 851 |
|
| 852 |
// A failed replacement, on invalid UTF-8 for one, drops the comment |
| 853 |
// rather than keep it whole. |
| 854 |
return ( null === $redacted ) ? '' : $open . $redacted . $close; |
| 855 |
} |
| 856 |
|
| 857 |
/** |
| 858 |
* Values in force of what a wp-config.php names |
| 859 |
* |
| 860 |
* Every user constant whose name appears in the file, the twelve of |
| 861 |
* WordPress wherever they were defined, and the environment variables the |
| 862 |
* file reads or sets. Arrays are walked to their leaves, since define() |
| 863 |
* takes arrays. Strings and numbers both: the first version of this counted |
| 864 |
* numbers and wiped the diff of any file with a large number in force (cross |
| 865 |
* review of 2.11.8), so they were dropped, and 2.11.10 had to bring them |
| 866 |
* back because a credential written as a number, which the wp.org review of |
| 867 |
* 2.11.9 reported, is exactly what this check has to be able to see. The |
| 868 |
* eight character floor is what keeps the old problem away. The readable |
| 869 |
* constants are left out, and so is anything shorter than that. |
| 870 |
* |
| 871 |
* @since 2.11.8 |
| 872 |
* |
| 873 |
* @param string $content Normalized wp-config.php content. |
| 874 |
* @return string[] |
| 875 |
*/ |
| 876 |
private function values_in_force( $content ) { |
| 877 |
$defined = get_defined_constants( true ); |
| 878 |
$user = isset( $defined['user'] ) ? $defined['user'] : array(); |
| 879 |
$names = self::$secret_constants; |
| 880 |
$values = array(); |
| 881 |
|
| 882 |
if ( preg_match_all( '/[A-Za-z_][A-Za-z0-9_]*/', (string) $content, $words ) ) { |
| 883 |
$names = array_merge( $names, $words[0] ); |
| 884 |
} |
| 885 |
|
| 886 |
foreach ( array_unique( $names ) as $name ) { |
| 887 |
if ( array_key_exists( $name, $user ) && ! in_array( $name, self::$readable_constants, true ) ) { |
| 888 |
$values = array_merge( $values, self::string_leaves( $user[ $name ] ) ); |
| 889 |
} |
| 890 |
} |
| 891 |
|
| 892 |
if ( preg_match_all( '/\b(?:getenv|putenv)\s*\(\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)|\$_ENV\s*\[\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)/', (string) $content, $env ) ) { |
| 893 |
foreach ( array_filter( array_merge( $env[1], $env[2] ) ) as $name ) { |
| 894 |
$value = getenv( $name ); |
| 895 |
|
| 896 |
if ( is_string( $value ) ) { |
| 897 |
$values[] = $value; |
| 898 |
} |
| 899 |
} |
| 900 |
} |
| 901 |
|
| 902 |
$long = array(); |
| 903 |
|
| 904 |
foreach ( $values as $value ) { |
| 905 |
if ( strlen( $value ) >= 8 ) { |
| 906 |
$long[ $value ] = $value; |
| 907 |
} |
| 908 |
} |
| 909 |
|
| 910 |
return array_values( $long ); |
| 911 |
} |
| 912 |
|
| 913 |
/** |
| 914 |
* Every string inside a constant value |
| 915 |
* |
| 916 |
* @since 2.11.8 |
| 917 |
* |
| 918 |
* @param mixed $value Constant value. |
| 919 |
* @return string[] |
| 920 |
*/ |
| 921 |
private static function string_leaves( $value ) { |
| 922 |
if ( is_array( $value ) ) { |
| 923 |
$leaves = array(); |
| 924 |
|
| 925 |
foreach ( $value as $item ) { |
| 926 |
$leaves = array_merge( $leaves, self::string_leaves( $item ) ); |
| 927 |
} |
| 928 |
|
| 929 |
return $leaves; |
| 930 |
} |
| 931 |
|
| 932 |
if ( is_string( $value ) ) { |
| 933 |
return array( $value ); |
| 934 |
} |
| 935 |
|
| 936 |
/* |
| 937 |
* A number is a value too. Until 2.11.10 this returned nothing for one, |
| 938 |
* so the output check had no way to see a credential written as |
| 939 |
* define( 'SERVICE_TOKEN', 12345678 ) and the redaction was left without |
| 940 |
* its safety net there. Booleans and null stay out on purpose: as text |
| 941 |
* they are '1' and '', which would match half the file. |
| 942 |
*/ |
| 943 |
return ( is_int( $value ) || is_float( $value ) ) ? array( (string) $value ) : array(); |
| 944 |
} |
| 945 |
|
| 946 |
/** |
| 947 |
* Values in force of the readable constants, the ones kept in the copy |
| 948 |
* |
| 949 |
* @since 2.11.8 |
| 950 |
* |
| 951 |
* @return string[] |
| 952 |
*/ |
| 953 |
private function readable_values_in_force() { |
| 954 |
$values = array(); |
| 955 |
|
| 956 |
foreach ( self::$readable_constants as $name ) { |
| 957 |
if ( defined( $name ) ) { |
| 958 |
$values = array_merge( $values, self::string_leaves( constant( $name ) ) ); |
| 959 |
} |
| 960 |
} |
| 961 |
|
| 962 |
return $values; |
| 963 |
} |
| 964 |
|
| 965 |
/** |
| 966 |
* Replace the values a root .htaccess can carry as credentials |
| 967 |
* |
| 968 |
* The .htaccess is not a secrets file, but it can hold a few: an |
| 969 |
* environment variable handed to PHP with SetEnv, an Authorization header |
| 970 |
* set for a backend, or a php_value with a password, a key, a licence or a |
| 971 |
* session store address with its auth in it. The directive and its name |
| 972 |
* stay, the value goes. Line based, which is how Apache reads it too. Since |
| 973 |
* the cross review of 2.11.8 also any request or response header whose name |
| 974 |
* reads like a credential (X-Api-Key, a cookie, a signature) and a |
| 975 |
* RewriteCond that compares against key=, token= or the like, the way a |
| 976 |
* staging site is opened with a secret in the query string. What it cannot |
| 977 |
* see is a credential written in any other shape. |
| 978 |
* |
| 979 |
* @since 2.11.8 |
| 980 |
* |
| 981 |
* @param string $content Normalized .htaccess content. |
| 982 |
* @return string |
| 983 |
*/ |
| 984 |
private function redact_server_secrets( $content ) { |
| 985 |
$redacted = preg_replace( |
| 986 |
array( |
| 987 |
'/^([ \t]*SetEnv[ \t]+\S+[ \t]+)\S.*$/mi', |
| 988 |
'/^([ \t]*(?:RequestHeader|Header)[ \t]+(?:always[ \t]+)?\S+[ \t]+[\w-]*(?:auth|key|token|secret|pass|cookie|sig)[\w-]*[ \t]+)\S.*$/mi', |
| 989 |
'/^([ \t]*php_(?:admin_)?value[ \t]+\S*(?:pass|pw|secret|key|token|licen|auth|save_path)\S*[ \t]+)\S.*$/mi', |
| 990 |
'/^([ \t]*RewriteCond[ \t]+\S+[ \t]+)\S*(?:key|token|secret|pass|auth|sig)[\w-]*=\S*/mi', |
| 991 |
), |
| 992 |
'${1}' . self::REDACTED_MARKER, |
| 993 |
(string) $content |
| 994 |
); |
| 995 |
|
| 996 |
return ( null === $redacted ) ? '' : $redacted; |
| 997 |
} |
| 998 |
|
| 999 |
/** |
| 1000 |
* Promote a per-site baseline to the network record before dropping it |
| 1001 |
* |
| 1002 |
* Up to 2.11.2 the baseline was a per-site option, so on a network every |
| 1003 |
* site kept its own copy of the same two files. Those copies go, but what a |
| 1004 |
* copy records is which version of the file the owner approved, and that |
| 1005 |
* has to survive: rebuilding the baseline from disk would take whatever is |
| 1006 |
* there right now as approved, so a wp-config.php modified and still |
| 1007 |
* awaiting review would be blessed in silence. |
| 1008 |
* |
| 1009 |
* WHICH copy becomes the network record is not a detail, and 2.11.3 got it |
| 1010 |
* wrong. This runs from the scan, under wp-cron, on whichever site gets |
| 1011 |
* traffic first, and the sweep from the main site can be hours away because |
| 1012 |
* it waits for a network administrator to open a dashboard. So on a network |
| 1013 |
* with traffic spread around, the record of the whole installation was |
| 1014 |
* whatever the first subsite to scan happened to hold. |
| 1015 |
* |
| 1016 |
* That is harmless while every copy agrees, which is the ordinary case. The |
| 1017 |
* reason they can disagree is the very thing 2.11.3 fixed: until then, |
| 1018 |
* approving a change to wp-config.php took manage_options, which on a |
| 1019 |
* network the administrator of every subsite holds. If a change was |
| 1020 |
* approved on some subsite while the main site still had it pending review, |
| 1021 |
* promoting that subsite's copy retires a warning nobody decided to retire. |
| 1022 |
* |
| 1023 |
* Hence the order, file by file: what the network record already holds |
| 1024 |
* wins, then the main site, then the site this runs on. Between the copies, |
| 1025 |
* the main site beats a subsite, which is @calzbert's point, reported after |
| 1026 |
* reading the 2.11.3 diff. |
| 1027 |
* |
| 1028 |
* What this does NOT protect, said plainly because an earlier wording |
| 1029 |
* claimed more: if the network record already holds a file, that entry |
| 1030 |
* wins, even when it was written from disk by the .htaccess writer on |
| 1031 |
* init:20 while a third-party edit was pending review. What survives is a |
| 1032 |
* file the network record does not hold yet, which is the wp-config.php |
| 1033 |
* case that 2.11.3 lost. The .htaccess case is pre-existing and needs the |
| 1034 |
* writers to pass their before-hash, see update_critical_file_baseline(). |
| 1035 |
* |
| 1036 |
* @since 2.11.4 |
| 1037 |
* |
| 1038 |
* @param array|null $per_site Baseline stored for the site this runs on. |
| 1039 |
* @return bool True when the network record covers everything the per-site |
| 1040 |
* copy had, which is the only case where dropping it is safe. |
| 1041 |
*/ |
| 1042 |
private function promote_per_site_baseline( $per_site ) { |
| 1043 |
$network = get_site_option( self::BASELINE_OPTION, array() ); |
| 1044 |
|
| 1045 |
if ( ! is_array( $network ) ) { |
| 1046 |
$network = array(); |
| 1047 |
} |
| 1048 |
|
| 1049 |
/* |
| 1050 |
* Three sources, filled in one from another, file by file. It used to |
| 1051 |
* be all or nothing: if the network record existed at all, this |
| 1052 |
* returned at once and the caller dropped the per-site copy anyway. |
| 1053 |
* |
| 1054 |
* That looked safe and was not, because the network record can be born |
| 1055 |
* holding ONE of the two files. maybe_sync_server_files() runs on init |
| 1056 |
* and rewrites the root .htaccess by itself, and the writer calls |
| 1057 |
* update_critical_file_baseline( '.htaccess' ), which creates the |
| 1058 |
* network option with that single entry. init runs before admin_init, |
| 1059 |
* so on a network on Apache this is the ordinary order of an update, |
| 1060 |
* not a race: the cleanup then found the option "already there", kept |
| 1061 |
* nothing, and deleted the per-site copies that held the approved |
| 1062 |
* record of wp-config.php. The next scan met a file it had never seen |
| 1063 |
* and stored whatever was on disk as approved, which is the silent |
| 1064 |
* blessing this whole function exists to prevent. Reproduced on the |
| 1065 |
* Multisite install on 10 sep 2026, found by a cross review. |
| 1066 |
* |
| 1067 |
* Order of authority: what the network already says wins, then the main |
| 1068 |
* site, then the site this runs on. Nothing is ever overwritten and |
| 1069 |
* nothing is dropped for being late. |
| 1070 |
*/ |
| 1071 |
$sources = array( $network ); |
| 1072 |
|
| 1073 |
if ( ! is_main_site() ) { |
| 1074 |
$from_main = get_blog_option( get_main_site_id(), self::BASELINE_OPTION, null ); |
| 1075 |
|
| 1076 |
if ( is_array( $from_main ) ) { |
| 1077 |
$sources[] = $from_main; |
| 1078 |
} |
| 1079 |
} |
| 1080 |
|
| 1081 |
if ( is_array( $per_site ) ) { |
| 1082 |
$sources[] = $per_site; |
| 1083 |
} |
| 1084 |
|
| 1085 |
$merged = array(); |
| 1086 |
|
| 1087 |
foreach ( $sources as $source ) { |
| 1088 |
foreach ( $source as $filename => $data ) { |
| 1089 |
if ( isset( $merged[ $filename ] ) || ! is_array( $data ) || ! isset( $data['hash'] ) ) { |
| 1090 |
continue; |
| 1091 |
} |
| 1092 |
|
| 1093 |
// Only the content carries secrets; the hash and the size, |
| 1094 |
// which are what say "this is the version that was approved", |
| 1095 |
// go over untouched. |
| 1096 |
if ( isset( $data['content'] ) && is_string( $data['content'] ) ) { |
| 1097 |
$data['content'] = $this->baseline_content( $filename, $data['content'] ); |
| 1098 |
} |
| 1099 |
|
| 1100 |
$merged[ $filename ] = $data; |
| 1101 |
} |
| 1102 |
} |
| 1103 |
|
| 1104 |
if ( array_diff_key( $merged, $network ) ) { |
| 1105 |
$this->write_baseline( $merged ); |
| 1106 |
} |
| 1107 |
|
| 1108 |
if ( ! is_array( $per_site ) ) { |
| 1109 |
return true; |
| 1110 |
} |
| 1111 |
|
| 1112 |
/* |
| 1113 |
* Is every file this copy had a record of now on the network record? |
| 1114 |
* Only then may the caller drop it. And the question is asked of what |
| 1115 |
* is STORED, not of $merged, which is only what this request MEANT to |
| 1116 |
* store. Asking $merged makes the answer true by construction, because |
| 1117 |
* the copy is one of the sources above, so the guard could never fire |
| 1118 |
* and redact_in_place() in the caller was unreachable code. |
| 1119 |
* |
| 1120 |
* The write does not always land, and the case that matters is not a |
| 1121 |
* broken database, it is the same race as the bug this function fixes. |
| 1122 |
* On a network updating from 2.11.2 the network option does not exist |
| 1123 |
* yet, so update_network_option() takes the $old_value === false branch |
| 1124 |
* and delegates to add_network_option() (wp-includes/option.php:2434). |
| 1125 |
* If another request created the option in between, that call either |
| 1126 |
* returns false without writing (option.php:2201) or, when this process |
| 1127 |
* still holds "does not exist" in its own notoptions cache, skips the |
| 1128 |
* check and INSERTs a second row: wp_sitemeta has no unique index on |
| 1129 |
* meta_key, so the record ends up duplicated and get_network_option() |
| 1130 |
* hands back whichever row comes first. Reproduced on the Multisite |
| 1131 |
* install on 10 sep 2026, with the .htaccess writer of init:20 racing a |
| 1132 |
* promotion: two rows, the approved hash of wp-config.php out of reach, |
| 1133 |
* and the per-site copy deleted all the same. Found by a cross review. |
| 1134 |
* |
| 1135 |
* Both cache keys go before rereading, and that is not belt and braces. |
| 1136 |
* add_network_option() caches the value it believes it wrote |
| 1137 |
* (option.php:2221), so a plain read hands back the very array that did |
| 1138 |
* not survive; and a stale notoptions would answer "no such option" |
| 1139 |
* without touching the database, which reads as "nothing is covered". |
| 1140 |
* Measured: without dropping the cache this guard still returns true. |
| 1141 |
*/ |
| 1142 |
$network_id = get_current_network_id(); |
| 1143 |
wp_cache_delete( $network_id . ':' . self::BASELINE_OPTION, 'site-options' ); |
| 1144 |
wp_cache_delete( $network_id . ':notoptions', 'site-options' ); |
| 1145 |
|
| 1146 |
$stored = get_site_option( self::BASELINE_OPTION, array() ); |
| 1147 |
|
| 1148 |
if ( ! is_array( $stored ) ) { |
| 1149 |
return false; |
| 1150 |
} |
| 1151 |
|
| 1152 |
foreach ( $per_site as $filename => $data ) { |
| 1153 |
if ( is_array( $data ) && isset( $data['hash'] ) && ! isset( $stored[ $filename ] ) ) { |
| 1154 |
return false; |
| 1155 |
} |
| 1156 |
} |
| 1157 |
|
| 1158 |
return true; |
| 1159 |
} |
| 1160 |
|
| 1161 |
/** |
| 1162 |
* Strip the secrets from a per-site copy that cannot be dropped yet |
| 1163 |
* |
| 1164 |
* The copy stays because it holds the only record of an approved file, but |
| 1165 |
* what it must not keep for one more minute is the database password and |
| 1166 |
* the eight keys and salts. The two things are separable and this is where |
| 1167 |
* they get separated. |
| 1168 |
* |
| 1169 |
* @since 2.11.4 |
| 1170 |
* |
| 1171 |
* @param array $per_site Baseline stored for the current site. |
| 1172 |
* @return void |
| 1173 |
*/ |
| 1174 |
private function redact_in_place( $per_site ) { |
| 1175 |
$changed = false; |
| 1176 |
|
| 1177 |
foreach ( $per_site as $filename => $data ) { |
| 1178 |
if ( ! is_array( $data ) || ! isset( $data['content'] ) || ! is_string( $data['content'] ) ) { |
| 1179 |
continue; |
| 1180 |
} |
| 1181 |
|
| 1182 |
$safe = $this->baseline_content( $filename, $data['content'] ); |
| 1183 |
|
| 1184 |
if ( $safe !== $data['content'] ) { |
| 1185 |
$per_site[ $filename ]['content'] = $safe; |
| 1186 |
$changed = true; |
| 1187 |
} |
| 1188 |
} |
| 1189 |
|
| 1190 |
if ( $changed ) { |
| 1191 |
update_option( self::BASELINE_OPTION, $per_site ); |
| 1192 |
} |
| 1193 |
} |
| 1194 |
|
| 1195 |
/** |
| 1196 |
* Network option recording the version whose results cleanup walked the network |
| 1197 |
* |
| 1198 |
* @since 2.11.8 |
| 1199 |
*/ |
| 1200 |
const RESULTS_SWEEP_OPTION = 'vigilante_results_sweep'; |
| 1201 |
|
| 1202 |
/** |
| 1203 |
* Clean the stored scan results of every site of the network, once per version |
| 1204 |
* |
| 1205 |
* redact_stored_results() runs per site from admin_init and from the scan, |
| 1206 |
* so a subsite with the module off whose dashboard nobody opens kept the |
| 1207 |
* lines of wp-config.php its last scan stored, with whatever that version |
| 1208 |
* failed to redact. Same gap 2.11.3 and 2.11.4 closed for the baseline copy; |
| 1209 |
* found for the results by the cross review of 2.11.8. It runs from the |
| 1210 |
* network sweep, for a network administrator on the main site, and has its |
| 1211 |
* own marker because the baseline sweep is already done on every network |
| 1212 |
* that updated through 2.11.4. |
| 1213 |
* |
| 1214 |
* @since 2.11.8 |
| 1215 |
*/ |
| 1216 |
private function maybe_sweep_network_results() { |
| 1217 |
if ( VIGILANTE_VERSION === get_site_option( self::RESULTS_SWEEP_OPTION ) ) { |
| 1218 |
return; |
| 1219 |
} |
| 1220 |
|
| 1221 |
update_site_option( self::RESULTS_SWEEP_OPTION, VIGILANTE_VERSION ); |
| 1222 |
|
| 1223 |
$site_ids = get_sites( |
| 1224 |
array( |
| 1225 |
'fields' => 'ids', |
| 1226 |
'number' => 0, |
| 1227 |
'network_id' => get_current_network_id(), |
| 1228 |
'update_site_meta_cache' => false, |
| 1229 |
) |
| 1230 |
); |
| 1231 |
|
| 1232 |
foreach ( $site_ids as $site_id ) { |
| 1233 |
switch_to_blog( $site_id ); |
| 1234 |
$this->redact_stored_results(); |
| 1235 |
restore_current_blog(); |
| 1236 |
} |
| 1237 |
} |
| 1238 |
|
| 1239 |
/** |
| 1240 |
* The diff of a shared file as a site that does not own it gets it |
| 1241 |
* |
| 1242 |
* No lines, and a flag the screens read to say where the lines are. |
| 1243 |
* |
| 1244 |
* @since 2.11.8 |
| 1245 |
* |
| 1246 |
* @return array |
| 1247 |
*/ |
| 1248 |
public static function network_only_diff() { |
| 1249 |
return array( |
| 1250 |
'added' => array(), |
| 1251 |
'removed' => array(), |
| 1252 |
'unavailable' => true, |
| 1253 |
'network' => true, |
| 1254 |
); |
| 1255 |
} |
| 1256 |
|
| 1257 |
/** |
| 1258 |
* Take out of the last stored scan what the baseline copy no longer keeps |
| 1259 |
* |
| 1260 |
* The results of the last scan are an option of each site, and the diff of |
| 1261 |
* a critical file travels inside them line by line, redacted the way the |
| 1262 |
* version that ran the scan redacted. Until 2.11.7 that let FTP_PASS and |
| 1263 |
* friends through, and on a network every subsite with the module on kept |
| 1264 |
* its own copy of the lines. This runs once per version with the rest of |
| 1265 |
* the cleanup: |
| 1266 |
* |
| 1267 |
* - Where the shared files do not belong to this site, no line is kept. |
| 1268 |
* - Lines of wp-config.php are dropped. A single line cannot be read as |
| 1269 |
* PHP reliably (half a heredoc is just words), and the next scan rebuilds |
| 1270 |
* them from the whole file. |
| 1271 |
* - Lines of .htaccess are directives, one per line, and are redacted in |
| 1272 |
* place. |
| 1273 |
* |
| 1274 |
* @since 2.11.8 |
| 1275 |
*/ |
| 1276 |
private function redact_stored_results() { |
| 1277 |
$results = get_option( 'vigilante_last_integrity_results' ); |
| 1278 |
|
| 1279 |
if ( ! is_array( $results ) || empty( $results['modified'] ) || ! is_array( $results['modified'] ) ) { |
| 1280 |
return; |
| 1281 |
} |
| 1282 |
|
| 1283 |
$owns = Vigilante_Settings::owns_shared_files(); |
| 1284 |
$changed = false; |
| 1285 |
|
| 1286 |
foreach ( $results['modified'] as $index => $item ) { |
| 1287 |
if ( ! is_array( $item ) || 'critical_config' !== ( $item['type'] ?? '' ) || ! isset( $item['diff'] ) || ! is_array( $item['diff'] ) ) { |
| 1288 |
continue; |
| 1289 |
} |
| 1290 |
|
| 1291 |
if ( ! $owns ) { |
| 1292 |
if ( empty( $item['diff']['network'] ) ) { |
| 1293 |
$results['modified'][ $index ]['diff'] = self::network_only_diff(); |
| 1294 |
$changed = true; |
| 1295 |
} |
| 1296 |
continue; |
| 1297 |
} |
| 1298 |
|
| 1299 |
if ( 'wp-config.php' === ( $item['file'] ?? '' ) ) { |
| 1300 |
if ( ! empty( $item['diff']['added'] ) || ! empty( $item['diff']['removed'] ) ) { |
| 1301 |
$results['modified'][ $index ]['diff'] = array( |
| 1302 |
'added' => array(), |
| 1303 |
'removed' => array(), |
| 1304 |
'unavailable' => true, |
| 1305 |
'rescan' => true, |
| 1306 |
); |
| 1307 |
$changed = true; |
| 1308 |
} |
| 1309 |
continue; |
| 1310 |
} |
| 1311 |
|
| 1312 |
foreach ( array( 'added', 'removed' ) as $side ) { |
| 1313 |
if ( empty( $item['diff'][ $side ] ) || ! is_array( $item['diff'][ $side ] ) ) { |
| 1314 |
continue; |
| 1315 |
} |
| 1316 |
|
| 1317 |
foreach ( $item['diff'][ $side ] as $line_index => $line ) { |
| 1318 |
if ( ! is_array( $line ) || ! isset( $line['content'] ) || ! is_string( $line['content'] ) ) { |
| 1319 |
continue; |
| 1320 |
} |
| 1321 |
|
| 1322 |
$safe = $this->redact_server_secrets( $line['content'] ); |
| 1323 |
|
| 1324 |
if ( $safe !== $line['content'] ) { |
| 1325 |
$results['modified'][ $index ]['diff'][ $side ][ $line_index ]['content'] = $safe; |
| 1326 |
$changed = true; |
| 1327 |
} |
| 1328 |
} |
| 1329 |
} |
| 1330 |
} |
| 1331 |
|
| 1332 |
if ( $changed ) { |
| 1333 |
update_option( 'vigilante_last_integrity_results', $results ); |
| 1334 |
} |
| 1335 |
} |
| 1336 |
|
| 1337 |
/** |
| 1338 |
* Clean up what earlier versions stored, wherever they stored it |
| 1339 |
* |
| 1340 |
* Two jobs, and the second one only exists on a network. |
| 1341 |
* |
| 1342 |
* The first: versions up to 2.11.1 kept the contents of wp-config.php in |
| 1343 |
* the baseline, credentials included, so what is already on disk is |
| 1344 |
* redacted in place. |
| 1345 |
* |
| 1346 |
* The second: up to 2.11.2 that baseline was a per-site option, so on a |
| 1347 |
* network every site had its own copy of the same file. This runs per site |
| 1348 |
* and removes that copy, because the baseline now lives in a single |
| 1349 |
* network option. Doing it here is what makes the cleanup reach a site |
| 1350 |
* whose dashboard nobody ever opens: this method is called from the scan |
| 1351 |
* as well as from admin_init, and the scan runs on every site through |
| 1352 |
* wp-cron with front-end traffic alone. |
| 1353 |
* |
| 1354 |
* The gate option stays per site on purpose. It records that THIS site has |
| 1355 |
* been cleaned, which is exactly the per-site fact being tracked. |
| 1356 |
* |
| 1357 |
* @since 2.11.2 |
| 1358 |
*/ |
| 1359 |
public function maybe_redact_stored_baseline() { |
| 1360 |
if ( VIGILANTE_VERSION === get_option( self::BASELINE_REDACTION_OPTION ) ) { |
| 1361 |
return; |
| 1362 |
} |
| 1363 |
|
| 1364 |
/* |
| 1365 |
* The per-site copy left behind by 2.11.2 and earlier. On a network it |
| 1366 |
* holds the database password and the eight keys and salts, so it goes, |
| 1367 |
* but never before what it records has been carried over: |
| 1368 |
* promote_per_site_baseline() explains why the record has to outlive |
| 1369 |
* the copy, and which copy wins when they disagree. Measured on the |
| 1370 |
* Multisite install while writing 2.11.3: without that, the first scan |
| 1371 |
* after the migration reported zero modified files where it had to |
| 1372 |
* report one. |
| 1373 |
*/ |
| 1374 |
$pending = false; |
| 1375 |
|
| 1376 |
if ( is_multisite() ) { |
| 1377 |
$per_site = get_option( self::BASELINE_OPTION, null ); |
| 1378 |
|
| 1379 |
if ( null !== $per_site ) { |
| 1380 |
if ( $this->promote_per_site_baseline( $per_site ) ) { |
| 1381 |
delete_option( self::BASELINE_OPTION ); |
| 1382 |
} elseif ( is_array( $per_site ) ) { |
| 1383 |
// Something this copy recorded is not on the network record |
| 1384 |
// yet, so it does not go: it is the only evidence of what |
| 1385 |
// was approved. The secrets do go, right now, because that |
| 1386 |
// part cannot wait for the next pass. |
| 1387 |
$this->redact_in_place( $per_site ); |
| 1388 |
$pending = true; |
| 1389 |
} |
| 1390 |
} |
| 1391 |
} |
| 1392 |
|
| 1393 |
$baseline = $this->read_baseline(); |
| 1394 |
|
| 1395 |
if ( is_array( $baseline ) ) { |
| 1396 |
$changed = false; |
| 1397 |
|
| 1398 |
foreach ( $baseline as $filename => $data ) { |
| 1399 |
if ( ! is_array( $data ) || ! isset( $data['content'] ) || ! is_string( $data['content'] ) ) { |
| 1400 |
continue; |
| 1401 |
} |
| 1402 |
|
| 1403 |
$safe = $this->baseline_content( $filename, $data['content'] ); |
| 1404 |
|
| 1405 |
if ( $safe !== $data['content'] ) { |
| 1406 |
$baseline[ $filename ]['content'] = $safe; |
| 1407 |
$changed = true; |
| 1408 |
} |
| 1409 |
} |
| 1410 |
|
| 1411 |
if ( $changed ) { |
| 1412 |
$this->write_baseline( $baseline ); |
| 1413 |
} |
| 1414 |
} |
| 1415 |
|
| 1416 |
$this->redact_stored_results(); |
| 1417 |
|
| 1418 |
/* |
| 1419 |
* The gate does not close while a per-site copy is still waiting to be |
| 1420 |
* promoted. Closing it would end the retries for a whole version: the |
| 1421 |
* copy would sit there unread, the file it records would be missing |
| 1422 |
* from the network record, and the next scan would take whatever is on |
| 1423 |
* disk as approved. Not closing it is not free, though, and the first |
| 1424 |
* wording here said "one option read": measured cold on the Multisite |
| 1425 |
* install, it is 6 SQL queries per admin request against 0 with the gate |
| 1426 |
* closed, admin-ajax.php and the heartbeat included, two of them from the |
| 1427 |
* cache invalidation in promote_per_site_baseline(). Acceptable only |
| 1428 |
* because it converges: the stuck case this guards against resolves on |
| 1429 |
* the next pass that gets its write through. |
| 1430 |
*/ |
| 1431 |
if ( ! $pending ) { |
| 1432 |
update_option( self::BASELINE_REDACTION_OPTION, VIGILANTE_VERSION, false ); |
| 1433 |
} |
| 1434 |
} |
| 1435 |
|
| 1436 |
/** |
| 1437 |
* Sweep the whole network once, so it does not wait for each site's cron |
| 1438 |
* |
| 1439 |
* The per-site cleanup above reaches a site when that site runs a scan or |
| 1440 |
* someone opens its dashboard, which on a quiet subsite can take a while. |
| 1441 |
* This walks every site once and gets it over with, and its marker is a |
| 1442 |
* network option so it does not repeat per site. |
| 1443 |
* |
| 1444 |
* Runs only on the main site of the network, where a network administrator |
| 1445 |
* works, and only there does it cost anything. |
| 1446 |
* |
| 1447 |
* @since 2.11.3 |
| 1448 |
*/ |
| 1449 |
public function maybe_sweep_network_baselines() { |
| 1450 |
if ( ! is_multisite() || ! is_main_site() ) { |
| 1451 |
return; |
| 1452 |
} |
| 1453 |
|
| 1454 |
/* |
| 1455 |
* A network administrator, and nobody else. admin_init fires for any |
| 1456 |
* logged-in visitor, a subscriber opening their own profile included, |
| 1457 |
* and this walks every site of the network writing to each one. What it |
| 1458 |
* removes is stale data that rebuilds itself, so the harm is small, but |
| 1459 |
* an action over the whole network belongs to whoever administers the |
| 1460 |
* network. The surface inventory cannot see this: it reads wp_ajax_*, |
| 1461 |
* admin_post_* and REST routes, and a hook on admin_init is outside its |
| 1462 |
* coverage by construction, which is exactly the blind spot written |
| 1463 |
* down as rule 23. |
| 1464 |
* |
| 1465 |
* The per-site cleanup is deliberately not gated the same way: it also |
| 1466 |
* runs from the scan, under wp-cron with no user at all, and it only |
| 1467 |
* touches the site it runs on. |
| 1468 |
*/ |
| 1469 |
if ( ! current_user_can( 'manage_network_options' ) ) { |
| 1470 |
return; |
| 1471 |
} |
| 1472 |
|
| 1473 |
$this->maybe_sweep_network_results(); |
| 1474 |
|
| 1475 |
$marker = get_site_option( self::BASELINE_SWEEP_OPTION ); |
| 1476 |
|
| 1477 |
/* |
| 1478 |
* Two markers, because there are two different things to remember and |
| 1479 |
* 2.11.3 only remembered one of them. |
| 1480 |
* |
| 1481 |
* The walk is marked BEFORE it starts, on purpose: on a very large |
| 1482 |
* network it may not finish inside one request, and repeating it on |
| 1483 |
* every admin page load would be worse than leaving the rest to each |
| 1484 |
* site's own scan. But 2.11.3 wrote VIGILANTE_VERSION there, so an |
| 1485 |
* interrupted walk was retried by the next release, which was the only |
| 1486 |
* thing that ever finished it. Writing a fixed literal instead, as the |
| 1487 |
* first draft of 2.11.4 did, stopped the pointless rearming and took |
| 1488 |
* that retry away with it: a walk cut short would never be resumed by |
| 1489 |
* any version. And "each site's own scan cleans the rest" only holds |
| 1490 |
* where the module is on; with it off, the cleanup is registered under |
| 1491 |
* is_admin() alone, so a subsite nobody opens is exactly what the sweep |
| 1492 |
* exists for. Found by a cross review on 10 sep 2026. |
| 1493 |
* |
| 1494 |
* So: the running version means "started here and did not finish", and |
| 1495 |
* the migration literal means "finished, never again". |
| 1496 |
*/ |
| 1497 |
if ( self::BASELINE_SWEEP_MIGRATION === $marker ) { |
| 1498 |
return; |
| 1499 |
} |
| 1500 |
|
| 1501 |
if ( VIGILANTE_VERSION === $marker ) { |
| 1502 |
return; |
| 1503 |
} |
| 1504 |
|
| 1505 |
update_site_option( self::BASELINE_SWEEP_OPTION, VIGILANTE_VERSION ); |
| 1506 |
|
| 1507 |
// Only this network. WP_Site_Query filters by network solely when |
| 1508 |
// network_id is given, so on a multi-network install the walk would |
| 1509 |
// otherwise reach the sites of other networks and promote their copies |
| 1510 |
// into this network's record (get_current_network_id() below does not |
| 1511 |
// change with switch_to_blog()). |
| 1512 |
$site_ids = get_sites( |
| 1513 |
array( |
| 1514 |
'fields' => 'ids', |
| 1515 |
'number' => 0, |
| 1516 |
'network_id' => get_current_network_id(), |
| 1517 |
'update_site_meta_cache' => false, |
| 1518 |
) |
| 1519 |
); |
| 1520 |
|
| 1521 |
$pending = 0; |
| 1522 |
|
| 1523 |
foreach ( $site_ids as $site_id ) { |
| 1524 |
switch_to_blog( $site_id ); |
| 1525 |
|
| 1526 |
$per_site = get_option( self::BASELINE_OPTION, null ); |
| 1527 |
|
| 1528 |
if ( null !== $per_site ) { |
| 1529 |
// Same care as the per-site cleanup, and the same helper, so |
| 1530 |
// the two paths cannot drift apart the way they nearly did. |
| 1531 |
if ( $this->promote_per_site_baseline( $per_site ) ) { |
| 1532 |
delete_option( self::BASELINE_OPTION ); |
| 1533 |
} elseif ( is_array( $per_site ) ) { |
| 1534 |
$this->redact_in_place( $per_site ); |
| 1535 |
$pending++; |
| 1536 |
} |
| 1537 |
} |
| 1538 |
|
| 1539 |
restore_current_blog(); |
| 1540 |
} |
| 1541 |
|
| 1542 |
// Finished, and with nothing left behind, so it never has to run again |
| 1543 |
// in any version. A site whose copy could not be promoted keeps the |
| 1544 |
// marker on the running version instead, which is what gets the walk |
| 1545 |
// retried by the next release. |
| 1546 |
if ( ! $pending ) { |
| 1547 |
update_site_option( self::BASELINE_SWEEP_OPTION, self::BASELINE_SWEEP_MIGRATION ); |
| 1548 |
} |
| 1549 |
} |
| 1550 |
|
| 1551 |
/** |
| 1552 |
* Critical root files to monitor against a stored baseline. |
| 1553 |
* These files have no official WordPress.org checksum because their |
| 1554 |
* content is unique per installation. |
| 1555 |
* |
| 1556 |
* @var array |
| 1557 |
*/ |
| 1558 |
private $critical_root_files = array( |
| 1559 |
'wp-config.php', |
| 1560 |
'.htaccess', |
| 1561 |
); |
| 1562 |
|
| 1563 |
/** |
| 1564 |
* Vigilante markers used in wp-config.php (constants block). |
| 1565 |
* |
| 1566 |
* @var array |
| 1567 |
*/ |
| 1568 |
private $wpconfig_markers = array( |
| 1569 |
array( '/* BEGIN Vigilante Security Constants */', '/* END Vigilante Security Constants */' ), |
| 1570 |
array( '/* BEGIN AyudaWP Security Constants */', '/* END AyudaWP Security Constants */' ), |
| 1571 |
); |
| 1572 |
|
| 1573 |
/** |
| 1574 |
* Vigilante marker for commented-out original constants in wp-config.php. |
| 1575 |
* |
| 1576 |
* @var string |
| 1577 |
*/ |
| 1578 |
private $wpconfig_original_marker = '// [VIGILANTE_ORIGINAL] '; |
| 1579 |
|
| 1580 |
/** |
| 1581 |
* Vigilante markers used in .htaccess (firewall + security headers). |
| 1582 |
* |
| 1583 |
* @var array |
| 1584 |
*/ |
| 1585 |
private $htaccess_markers = array( |
| 1586 |
array( '# BEGIN Vigilante Protection', '# END Vigilante Protection' ), |
| 1587 |
array( '# BEGIN Vigilante Security Headers', '# END Vigilante Security Headers' ), |
| 1588 |
); |
| 1589 |
|
| 1590 |
/** |
| 1591 |
* Whether the core is being compared with the English checksums because |
| 1592 |
* WordPress.org has none for the language of the site. Set by |
| 1593 |
* get_core_checksums(). |
| 1594 |
* |
| 1595 |
* @var bool |
| 1596 |
*/ |
| 1597 |
private $core_checksums_are_english = false; |
| 1598 |
|
| 1599 |
/** |
| 1600 |
* Core files known to produce false positives in checksum comparison. |
| 1601 |
* These are skipped during core scanning: the readme and the license vary |
| 1602 |
* by locale and are not code. version.php used to be here too, and is now |
| 1603 |
* compared (see matches_core_of_other_locale()). |
| 1604 |
* |
| 1605 |
* @var array |
| 1606 |
*/ |
| 1607 |
private $core_known_false_positives = array( |
| 1608 |
'readme.html', |
| 1609 |
'license.txt', |
| 1610 |
'licencia.txt', |
| 1611 |
); |
| 1612 |
|
| 1613 |
/** |
| 1614 |
* Plugin files known to produce false positives in checksum comparison. |
| 1615 |
* Readme files frequently differ between WordPress.org API checksums and |
| 1616 |
* the actual installed version due to encoding, line endings, or locale. |
| 1617 |
* |
| 1618 |
* @var array |
| 1619 |
*/ |
| 1620 |
private $plugin_known_false_positives = array( |
| 1621 |
'readme.txt', |
| 1622 |
'readme.md', |
| 1623 |
); |
| 1624 |
|
| 1625 |
/** |
| 1626 |
* Legitimate non-PHP files commonly found in WordPress root. |
| 1627 |
* These are reported as 'additional' (informational), not suspicious. |
| 1628 |
* Dotfiles (e.g. .htaccess) are skipped entirely by the root scanner. |
| 1629 |
* |
| 1630 |
* @var array |
| 1631 |
*/ |
| 1632 |
private $known_safe_root_files = array( |
| 1633 |
'robots.txt', |
| 1634 |
'security.txt', |
| 1635 |
'humans.txt', |
| 1636 |
'llms.txt', |
| 1637 |
'llms-full.txt', |
| 1638 |
'ads.txt', |
| 1639 |
'app-ads.txt', |
| 1640 |
'favicon.ico', |
| 1641 |
'favicon.png', |
| 1642 |
'favicon.svg', |
| 1643 |
'apple-touch-icon.png', |
| 1644 |
'apple-touch-icon-precomposed.png', |
| 1645 |
'sitemap.xml', |
| 1646 |
'sitemap_index.xml', |
| 1647 |
'bingsiteauth.xml', |
| 1648 |
'livesearchsiteauth.xml', |
| 1649 |
'google-site-verification.html', |
| 1650 |
'php.ini', |
| 1651 |
// PHP error logs commonly created by managed hosting (SiteGround, Hostinger, cPanel). |
| 1652 |
// Not executable; reported as "additional" instead of "suspicious". |
| 1653 |
'php_errorlog', |
| 1654 |
'error_log', |
| 1655 |
); |
| 1656 |
|
| 1657 |
/** |
| 1658 |
* Legacy WordPress core files removed from newer versions but kept on |
| 1659 |
* existing installs to prevent breakage. These are dead code, not malware. |
| 1660 |
* Marked as 'extra' (additional) instead of 'suspicious' with advice to delete. |
| 1661 |
* |
| 1662 |
* @see https://core.trac.wordpress.org/ticket/48540 |
| 1663 |
* @see https://core.trac.wordpress.org/ticket/18384 |
| 1664 |
* @var array |
| 1665 |
*/ |
| 1666 |
private $legacy_core_root_files = array( |
| 1667 |
'wp-feed.php', |
| 1668 |
'wp-rss.php', |
| 1669 |
'wp-rss2.php', |
| 1670 |
'wp-rdf.php', |
| 1671 |
'wp-atom.php', |
| 1672 |
'wp-commentsrss2.php', |
| 1673 |
'wp-pass.php', |
| 1674 |
'wp-register.php', |
| 1675 |
); |
| 1676 |
|
| 1677 |
/** |
| 1678 |
* Constructor |
| 1679 |
* |
| 1680 |
* @param Vigilante_Settings $settings Settings instance. |
| 1681 |
* @param Vigilante_Database|null $database Database instance. |
| 1682 |
* @param Vigilante_Activity_Log|null $activity_log Activity log instance. |
| 1683 |
*/ |
| 1684 |
public function __construct( $settings, $database = null, $activity_log = null ) { |
| 1685 |
$this->settings = $settings; |
| 1686 |
$this->database = $database; |
| 1687 |
$this->activity_log = $activity_log; |
| 1688 |
$this->options = $settings ? $settings->get_section( 'file_integrity' ) : array(); |
| 1689 |
$this->wp_version = get_bloginfo( 'version' ); |
| 1690 |
$this->ignored_files = get_option( 'vigilante_ignored_files', array() ); |
| 1691 |
} |
| 1692 |
|
| 1693 |
/** |
| 1694 |
* Register the hooks of the scanner itself |
| 1695 |
* |
| 1696 |
* Until 2.11.4 all of this lived in the constructor, and the constructor is |
| 1697 |
* called from a dozen places: the module gate, the activator, the hook that |
| 1698 |
* runs after Vigilant writes a watched file, and the admin handlers that |
| 1699 |
* only want the class as a tool. Every |
| 1700 |
* one of them registered these hooks again, and one runs during admin_init |
| 1701 |
* itself. Registering apart from constructing means a `new` is only a |
| 1702 |
* `new`, and it is what lets the cleanup below stand on its own. |
| 1703 |
* |
| 1704 |
* @since 2.11.4 |
| 1705 |
*/ |
| 1706 |
public function init_hooks() { |
| 1707 |
// Schedule automated scans only if options available |
| 1708 |
if ( ! empty( $this->options['auto_scan'] ) ) { |
| 1709 |
add_action( 'vigilante_file_integrity_scan', array( $this, 'run_scheduled_scan' ) ); |
| 1710 |
$this->schedule_scan(); |
| 1711 |
} |
| 1712 |
|
| 1713 |
// Post-update verification: verify any just-updated plugin/theme against |
| 1714 |
// WordPress.org immediately, and open a short grace window so the |
| 1715 |
// scheduled scan does not raise false positives while wp.org is still |
| 1716 |
// publishing the new version's checksums. Registered regardless of |
| 1717 |
// auto_scan because it reacts to update events, not to the schedule. |
| 1718 |
add_action( 'upgrader_process_complete', array( $this, 'on_upgrade_complete' ), 20, 2 ); |
| 1719 |
add_action( 'vigilante_fi_postupdate_verify', array( $this, 'run_postupdate_verify' ) ); |
| 1720 |
} |
| 1721 |
|
| 1722 |
/** |
| 1723 |
* Register the cleanup of what earlier versions stored, module on or off |
| 1724 |
* |
| 1725 |
* These two are not integrity monitoring. They take out of the database |
| 1726 |
* something the plugin stored and should not have, which is the copy of |
| 1727 |
* wp-config.php carrying the database password and the eight keys and |
| 1728 |
* salts. Whoever switched the module off did not decide to keep that, and |
| 1729 |
* for that person the cleanup matters more, not less: they are not going |
| 1730 |
* to pass through the scanner again. |
| 1731 |
* |
| 1732 |
* Until 2.11.4 these were registered in the constructor, so they only ran |
| 1733 |
* where the module was on. A site with the module off kept the credentials |
| 1734 |
* with 2.11.3 installed, and a network whose main site had it off lost the |
| 1735 |
* sweep too, which was the one path that reached the sites nobody visits. |
| 1736 |
* Reported by @calzbert after reading the 2.11.3 diff. |
| 1737 |
* |
| 1738 |
* On admin_init because that is where the baseline is looked at, and it |
| 1739 |
* does one option read per admin request until it has run once. |
| 1740 |
* |
| 1741 |
* @since 2.11.4 |
| 1742 |
*/ |
| 1743 |
public function init_cleanup_hooks() { |
| 1744 |
add_action( 'admin_init', array( $this, 'maybe_redact_stored_baseline' ) ); |
| 1745 |
add_action( 'admin_init', array( $this, 'maybe_sweep_network_baselines' ) ); |
| 1746 |
add_action( 'admin_init', array( $this, 'maybe_claim_owned_blocks_on_admin' ) ); |
| 1747 |
} |
| 1748 |
|
| 1749 |
/** |
| 1750 |
* Check if scan time limit has been exceeded |
| 1751 |
* |
| 1752 |
* @return bool True if time exceeded. |
| 1753 |
*/ |
| 1754 |
private function is_time_exceeded() { |
| 1755 |
if ( 0 === $this->scan_start_time ) { |
| 1756 |
return false; |
| 1757 |
} |
| 1758 |
return ( microtime( true ) - $this->scan_start_time ) > $this->max_scan_time; |
| 1759 |
} |
| 1760 |
|
| 1761 |
/** |
| 1762 |
* Schedule automated scans |
| 1763 |
*/ |
| 1764 |
private function schedule_scan() { |
| 1765 |
$frequency = $this->options['scan_frequency'] ?? 'daily'; |
| 1766 |
|
| 1767 |
if ( ! wp_next_scheduled( 'vigilante_file_integrity_scan' ) ) { |
| 1768 |
wp_schedule_event( time(), $frequency, 'vigilante_file_integrity_scan' ); |
| 1769 |
} |
| 1770 |
} |
| 1771 |
|
| 1772 |
/** |
| 1773 |
* Run a scheduled scan |
| 1774 |
*/ |
| 1775 |
public function run_scheduled_scan() { |
| 1776 |
$results = $this->run_scan(); |
| 1777 |
|
| 1778 |
// Store last scan time |
| 1779 |
update_option( 'vigilante_last_integrity_scan', time() ); |
| 1780 |
update_option( 'vigilante_last_integrity_results', $results ); |
| 1781 |
} |
| 1782 |
|
| 1783 |
/** |
| 1784 |
* React to a completed plugin/theme update (upgrader_process_complete). |
| 1785 |
* |
| 1786 |
* Opens a short grace window for each updated slug (so the scheduled scan |
| 1787 |
* skips it and the checksum cache is bypassed) and schedules an immediate |
| 1788 |
* verification against WordPress.org. This stops the post-update "files |
| 1789 |
* don't match WordPress.org" false positives and, when WP-Cron is healthy, |
| 1790 |
* verifies the update against WordPress.org right away instead of waiting |
| 1791 |
* for the next scheduled scan. The grace window is intentionally short (30 |
| 1792 |
* minutes) so that if WP-Cron never fires the verification, the normal scan |
| 1793 |
* resumes for the slug rather than leaving it unscanned. |
| 1794 |
* |
| 1795 |
* Runs as the old plugin code with the new files already on disk, so slugs |
| 1796 |
* are read from $hook_extra, not from in-memory version constants. |
| 1797 |
* |
| 1798 |
* @param WP_Upgrader|mixed $upgrader Upgrader instance (unused). |
| 1799 |
* @param array $hook_extra Update context. |
| 1800 |
*/ |
| 1801 |
public function on_upgrade_complete( $upgrader, $hook_extra ) { |
| 1802 |
unset( $upgrader ); |
| 1803 |
if ( ! is_array( $hook_extra ) || 'update' !== ( $hook_extra['action'] ?? '' ) ) { |
| 1804 |
return; |
| 1805 |
} |
| 1806 |
|
| 1807 |
$type = $hook_extra['type'] ?? ''; |
| 1808 |
$targets = array(); // type => list of slugs. |
| 1809 |
|
| 1810 |
if ( 'plugin' === $type ) { |
| 1811 |
$files = array(); |
| 1812 |
if ( ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) { |
| 1813 |
$files = $hook_extra['plugins']; |
| 1814 |
} elseif ( ! empty( $hook_extra['plugin'] ) ) { |
| 1815 |
$files = array( $hook_extra['plugin'] ); |
| 1816 |
} |
| 1817 |
foreach ( $files as $file ) { |
| 1818 |
$slug = dirname( (string) $file ); |
| 1819 |
// Vigilant itself is verified immediately (not after 90 s) by |
| 1820 |
// Vigilante_Self_Integrity::handle_upgrader() when the |
| 1821 |
// self-check is on; do not also open a grace window for it. |
| 1822 |
// Compared with the folder it really lives in, not the literal |
| 1823 |
// slug, so a renamed folder is skipped the same way. |
| 1824 |
if ( dirname( VIGILANTE_PLUGIN_BASENAME ) === $slug && Vigilante_Self_Integrity::is_on() ) { |
| 1825 |
continue; |
| 1826 |
} |
| 1827 |
if ( '.' !== $slug && '' !== $slug ) { |
| 1828 |
$targets['plugin'][] = $slug; |
| 1829 |
} |
| 1830 |
} |
| 1831 |
} elseif ( 'theme' === $type && ! empty( $hook_extra['themes'] ) && is_array( $hook_extra['themes'] ) ) { |
| 1832 |
foreach ( $hook_extra['themes'] as $slug ) { |
| 1833 |
$slug = (string) $slug; |
| 1834 |
if ( '' !== $slug ) { |
| 1835 |
$targets['theme'][] = $slug; |
| 1836 |
} |
| 1837 |
} |
| 1838 |
} |
| 1839 |
|
| 1840 |
if ( empty( $targets ) ) { |
| 1841 |
return; |
| 1842 |
} |
| 1843 |
|
| 1844 |
// Open a short grace window per slug (30 minutes; closed earlier once the |
| 1845 |
// verifier runs). Kept short on purpose: with WP-Cron disabled the |
| 1846 |
// verifier never fires, so a longer window would leave a just-updated |
| 1847 |
// slug unscanned. After it expires the normal scan resumes. |
| 1848 |
foreach ( $targets as $t => $slugs ) { |
| 1849 |
foreach ( array_unique( $slugs ) as $slug ) { |
| 1850 |
set_transient( 'vigilante_fi_grace_' . $t . '_' . md5( $slug ), 1, 30 * MINUTE_IN_SECONDS ); |
| 1851 |
} |
| 1852 |
} |
| 1853 |
|
| 1854 |
// Verify shortly after the update settles. A single delayed event keeps |
| 1855 |
// the heavy work out of the update request itself. |
| 1856 |
if ( ! wp_next_scheduled( 'vigilante_fi_postupdate_verify', array( $targets ) ) ) { |
| 1857 |
wp_schedule_single_event( time() + 90, 'vigilante_fi_postupdate_verify', array( $targets ) ); |
| 1858 |
} |
| 1859 |
} |
| 1860 |
|
| 1861 |
/** |
| 1862 |
* Immediate post-update verification callback (vigilante_fi_postupdate_verify). |
| 1863 |
* |
| 1864 |
* @param array $targets type => list of slugs. |
| 1865 |
*/ |
| 1866 |
public function run_postupdate_verify( $targets ) { |
| 1867 |
if ( ! is_array( $targets ) ) { |
| 1868 |
return; |
| 1869 |
} |
| 1870 |
foreach ( $targets as $type => $slugs ) { |
| 1871 |
if ( 'plugin' !== $type && 'theme' !== $type ) { |
| 1872 |
continue; |
| 1873 |
} |
| 1874 |
foreach ( array_unique( (array) $slugs ) as $slug ) { |
| 1875 |
$this->verify_updated_slug( $type, (string) $slug ); |
| 1876 |
} |
| 1877 |
} |
| 1878 |
} |
| 1879 |
|
| 1880 |
/** |
| 1881 |
* Verify a single just-updated plugin/theme against fresh wp.org checksums. |
| 1882 |
* |
| 1883 |
* The grace window forces get_*_checksums() to fetch a live manifest, so this |
| 1884 |
* never compares against a manifest cached during wp.org's propagation lag. |
| 1885 |
* Outcomes: checksums not published yet => leave the window to expire and let |
| 1886 |
* the next scan re-check; all files match => close the window early; a file |
| 1887 |
* matches no published hash => a genuine mismatch (right after a legit update |
| 1888 |
* that points at a tampered package), logged as a warning, and the window is |
| 1889 |
* closed so the finding also surfaces in the normal scan. |
| 1890 |
* |
| 1891 |
* @param string $type 'plugin' or 'theme'. |
| 1892 |
* @param string $slug Slug. |
| 1893 |
*/ |
| 1894 |
private function verify_updated_slug( $type, $slug ) { |
| 1895 |
$grace_key = 'vigilante_fi_grace_' . $type . '_' . md5( $slug ); |
| 1896 |
|
| 1897 |
// Defensive skip for the first 2.11.x -> 3.0.x update: the OLD code in |
| 1898 |
// memory scheduled this event including Vigilant's own slug, and by the |
| 1899 |
// time it fires the NEW code (this one) is running with the self-check |
| 1900 |
// handling Vigilant on its own. |
| 1901 |
if ( 'plugin' === $type && dirname( VIGILANTE_PLUGIN_BASENAME ) === $slug && Vigilante_Self_Integrity::is_on() ) { |
| 1902 |
delete_transient( $grace_key ); |
| 1903 |
return; |
| 1904 |
} |
| 1905 |
|
| 1906 |
if ( 'plugin' === $type ) { |
| 1907 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 1908 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 1909 |
} |
| 1910 |
$version = ''; |
| 1911 |
$name = $slug; |
| 1912 |
foreach ( get_plugins() as $file => $data ) { |
| 1913 |
if ( dirname( $file ) === $slug ) { |
| 1914 |
$version = $data['Version'] ?? ''; |
| 1915 |
$name = $data['Name'] ?? $slug; |
| 1916 |
break; |
| 1917 |
} |
| 1918 |
} |
| 1919 |
$checksums = $this->get_plugin_checksums( $slug, $version ); |
| 1920 |
$base_dir = WP_PLUGIN_DIR . '/' . $slug; |
| 1921 |
} else { |
| 1922 |
$theme = wp_get_theme( $slug ); |
| 1923 |
if ( ! $theme->exists() ) { |
| 1924 |
delete_transient( $grace_key ); |
| 1925 |
return; |
| 1926 |
} |
| 1927 |
$version = $theme->get( 'Version' ); |
| 1928 |
$name = $theme->get( 'Name' ); |
| 1929 |
$checksums = $this->get_theme_checksums( $slug, $version ); |
| 1930 |
$base_dir = $theme->get_stylesheet_directory(); |
| 1931 |
} |
| 1932 |
|
| 1933 |
// Checksums not available yet (propagation lag): leave the grace window |
| 1934 |
// to expire; the next scheduled scan re-verifies once wp.org publishes. |
| 1935 |
if ( is_wp_error( $checksums ) || 'not_found' === $checksums || ! is_array( $checksums ) ) { |
| 1936 |
if ( $this->activity_log ) { |
| 1937 |
$this->activity_log->log( |
| 1938 |
'file', |
| 1939 |
'postupdate_pending', |
| 1940 |
sprintf( |
| 1941 |
/* translators: 1: Plugin or theme name. */ |
| 1942 |
__( 'Post-update verification pending for %1$s: WordPress.org has not published the new version checksums yet. It will be re-verified automatically.', 'vigilante' ), |
| 1943 |
$name |
| 1944 |
), |
| 1945 |
array( |
| 1946 |
'type' => $type, |
| 1947 |
'slug' => $slug, |
| 1948 |
'version' => $version, |
| 1949 |
), |
| 1950 |
'info' |
| 1951 |
); |
| 1952 |
} |
| 1953 |
return; |
| 1954 |
} |
| 1955 |
|
| 1956 |
// Compare every shipped file against the fresh manifest. |
| 1957 |
$mismatched = array(); |
| 1958 |
foreach ( $checksums as $file => $expected ) { |
| 1959 |
if ( in_array( $file, $this->plugin_known_false_positives, true ) ) { |
| 1960 |
continue; |
| 1961 |
} |
| 1962 |
$path = $base_dir . '/' . $file; |
| 1963 |
if ( $this->is_path_excluded( $path ) || $this->is_extension_excluded( $path ) ) { |
| 1964 |
continue; |
| 1965 |
} |
| 1966 |
// Honor the user ignore list, exactly as run_scan()'s filter_ignored() |
| 1967 |
// does, so the verifier never warns about a file the user silenced. |
| 1968 |
$rel = $type . 's/' . $slug . '/' . $file; |
| 1969 |
if ( in_array( $rel, (array) $this->ignored_files, true ) ) { |
| 1970 |
continue; |
| 1971 |
} |
| 1972 |
if ( ! file_exists( $path ) ) { |
| 1973 |
continue; |
| 1974 |
} |
| 1975 |
if ( ! $this->hash_matches_published( $path, $expected ) ) { |
| 1976 |
$mismatched[] = $rel; |
| 1977 |
} |
| 1978 |
} |
| 1979 |
|
| 1980 |
// Verified clean: close the grace window so normal scanning resumes. |
| 1981 |
if ( empty( $mismatched ) ) { |
| 1982 |
delete_transient( $grace_key ); |
| 1983 |
if ( $this->activity_log ) { |
| 1984 |
$this->activity_log->log( |
| 1985 |
'file', |
| 1986 |
'postupdate_verified', |
| 1987 |
sprintf( |
| 1988 |
/* translators: 1: Plugin or theme name. */ |
| 1989 |
__( 'Post-update verification passed: %1$s matches the WordPress.org distribution.', 'vigilante' ), |
| 1990 |
$name |
| 1991 |
), |
| 1992 |
array( |
| 1993 |
'type' => $type, |
| 1994 |
'slug' => $slug, |
| 1995 |
'version' => $version, |
| 1996 |
), |
| 1997 |
'info' |
| 1998 |
); |
| 1999 |
} |
| 2000 |
return; |
| 2001 |
} |
| 2002 |
|
| 2003 |
// Genuine mismatch right after a legitimate update: likely a tampered |
| 2004 |
// package. Close the window so the normal scan also surfaces it, and log |
| 2005 |
// a warning (Audit Alerts escalates warnings if configured). |
| 2006 |
delete_transient( $grace_key ); |
| 2007 |
if ( $this->activity_log ) { |
| 2008 |
$this->activity_log->log( |
| 2009 |
'file', |
| 2010 |
'postupdate_mismatch', |
| 2011 |
sprintf( |
| 2012 |
/* translators: 1: Number of files, 2: Plugin or theme name. */ |
| 2013 |
_n( |
| 2014 |
'Post-update integrity check failed: %1$d file in %2$s does not match the WordPress.org distribution.', |
| 2015 |
'Post-update integrity check failed: %1$d files in %2$s do not match the WordPress.org distribution.', |
| 2016 |
count( $mismatched ), |
| 2017 |
'vigilante' |
| 2018 |
), |
| 2019 |
count( $mismatched ), |
| 2020 |
$name |
| 2021 |
), |
| 2022 |
array( |
| 2023 |
'type' => $type, |
| 2024 |
'slug' => $slug, |
| 2025 |
'version' => $version, |
| 2026 |
'files' => array_slice( $mismatched, 0, 50 ), |
| 2027 |
), |
| 2028 |
'warning' |
| 2029 |
); |
| 2030 |
} |
| 2031 |
} |
| 2032 |
|
| 2033 |
/** |
| 2034 |
* Run a full integrity scan |
| 2035 |
* |
| 2036 |
* @return array Scan results. |
| 2037 |
*/ |
| 2038 |
public function run_scan() { |
| 2039 |
// Initialize scan timer |
| 2040 |
$this->scan_start_time = microtime( true ); |
| 2041 |
|
| 2042 |
$results = array( |
| 2043 |
'scanned' => 0, |
| 2044 |
'ok' => 0, |
| 2045 |
'modified' => array(), |
| 2046 |
'missing' => array(), |
| 2047 |
'suspicious' => array(), |
| 2048 |
'extra' => array(), |
| 2049 |
'new' => array(), |
| 2050 |
'errors' => array(), |
| 2051 |
'scan_time' => 0, |
| 2052 |
'incomplete' => false, |
| 2053 |
); |
| 2054 |
|
| 2055 |
// Use settings from options page |
| 2056 |
$options = is_array( $this->options ) ? $this->options : array(); |
| 2057 |
|
| 2058 |
// Vigilant self-check runs FIRST and exempt from the time budget: |
| 2059 |
// ~60 small-file hashes cost < 50 ms and the guardian must never be |
| 2060 |
// dropped by the budget on plugin-heavy sites. User exclusions do not |
| 2061 |
// apply to it (see Vigilante_Self_Integrity::run_check()). |
| 2062 |
if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) { |
| 2063 |
require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php'; |
| 2064 |
} |
| 2065 |
if ( Vigilante_Self_Integrity::is_on() ) { |
| 2066 |
if ( ! class_exists( 'Vigilante_Self_Integrity' ) ) { |
| 2067 |
require_once VIGILANTE_INCLUDES_DIR . 'class-self-integrity.php'; |
| 2068 |
} |
| 2069 |
$self = new Vigilante_Self_Integrity( $this->settings, $this->activity_log ); |
| 2070 |
$self_result = $self->run_check( 'scan' ); |
| 2071 |
// run_check() above updates this site's own state (status line and |
| 2072 |
// the analyzer check keep working everywhere). The plugin files are |
| 2073 |
// shared by the whole installation, so the self findings are folded |
| 2074 |
// Self-protection has its own alert, and it does not travel in the |
| 2075 |
// scan digest any more. That digest is governed by a notification |
| 2076 |
// setting that can be switched off, and switching off "tell me about |
| 2077 |
// changed files" was also switching off the alarm of the plugin |
| 2078 |
// itself. So the findings stay out of the scan results (they have |
| 2079 |
// their own block in File Integrity, with what each one means and how |
| 2080 |
// to repair it) and a critical one sends its own email from here, |
| 2081 |
// wherever the scan runs. maybe_send_self_alert() keeps it to the |
| 2082 |
// site that owns the shared files and dedupes by set of findings. |
| 2083 |
foreach ( (array) $self_result['findings'] as $self_finding ) { |
| 2084 |
if ( 'critical' === ( $self_finding['severity'] ?? '' ) ) { |
| 2085 |
$self->maybe_send_self_alert( $self_result['findings'], 'scan' ); |
| 2086 |
break; |
| 2087 |
} |
| 2088 |
} |
| 2089 |
} |
| 2090 |
|
| 2091 |
// What the time does not reach. A scan that runs out of time used to |
| 2092 |
// say only that it was partial, and it starts from the beginning |
| 2093 |
// every time, so on a large site the same plugins and themes were |
| 2094 |
// left out on every scan with nobody told which. They are named. |
| 2095 |
$not_checked = array( |
| 2096 |
'areas' => array(), |
| 2097 |
'plugins' => array(), |
| 2098 |
'themes' => array(), |
| 2099 |
); |
| 2100 |
|
| 2101 |
// And the folders of the plugins and themes it did check, which is |
| 2102 |
// what the tab offers to exclude so that a scan by hand reaches the |
| 2103 |
// rest (see folders_to_exclude()). |
| 2104 |
$checked_folders = array(); |
| 2105 |
|
| 2106 |
// Scan uploads for suspicious files FIRST (highest security priority) |
| 2107 |
// PHP files in uploads are almost always malware |
| 2108 |
if ( ! empty( $options['scan_uploads'] ) && ! $this->is_time_exceeded() ) { |
| 2109 |
$upload_results = $this->scan_uploads(); |
| 2110 |
$results['suspicious'] = array_merge( $results['suspicious'], $upload_results['suspicious'] ); |
| 2111 |
$results['extra'] = array_merge( $results['extra'], $upload_results['extra'] ); |
| 2112 |
$results['errors'] = array_merge( $results['errors'], $upload_results['errors'] ?? array() ); |
| 2113 |
} elseif ( ! empty( $options['scan_uploads'] ) ) { |
| 2114 |
$not_checked['areas'][] = 'uploads'; |
| 2115 |
} |
| 2116 |
|
| 2117 |
// Scan core files |
| 2118 |
if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) { |
| 2119 |
$core_results = $this->scan_core_files(); |
| 2120 |
$results = $this->merge_results( $results, $core_results ); |
| 2121 |
} elseif ( ! empty( $options['scan_core'] ) ) { |
| 2122 |
$not_checked['areas'][] = 'core'; |
| 2123 |
} |
| 2124 |
|
| 2125 |
// Scan root directory for non-core files (PHP = suspicious, others = additional) |
| 2126 |
// Runs after core scan so checksums are already cached |
| 2127 |
if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) { |
| 2128 |
$root_results = $this->scan_root_files(); |
| 2129 |
$results['suspicious'] = array_merge( $results['suspicious'], $root_results['suspicious'] ); |
| 2130 |
$results['extra'] = array_merge( $results['extra'], $root_results['extra'] ); |
| 2131 |
} elseif ( ! empty( $options['scan_core'] ) ) { |
| 2132 |
$not_checked['areas'][] = 'core'; |
| 2133 |
} |
| 2134 |
|
| 2135 |
// Files added to wp-admin or wp-includes, which the comparison with |
| 2136 |
// the checksums never visits because the checksums do not name them. |
| 2137 |
if ( ! empty( $options['scan_core'] ) && ! $this->is_time_exceeded() ) { |
| 2138 |
$folder_results = $this->scan_core_folders(); |
| 2139 |
$results['suspicious'] = array_merge( $results['suspicious'], $folder_results['suspicious'] ); |
| 2140 |
$results['extra'] = array_merge( $results['extra'], $folder_results['extra'] ); |
| 2141 |
$results['errors'] = array_merge( $results['errors'], $folder_results['errors'] ); |
| 2142 |
} elseif ( ! empty( $options['scan_core'] ) ) { |
| 2143 |
$not_checked['areas'][] = 'core'; |
| 2144 |
} |
| 2145 |
|
| 2146 |
// Scan critical config files (wp-config.php, .htaccess) against stored baseline |
| 2147 |
if ( ! empty( $options['scan_critical_config'] ) && ! $this->is_time_exceeded() ) { |
| 2148 |
$critical_results = $this->scan_critical_root_files(); |
| 2149 |
$results['modified'] = array_merge( $results['modified'], $critical_results ); |
| 2150 |
} elseif ( ! empty( $options['scan_critical_config'] ) ) { |
| 2151 |
$not_checked['areas'][] = 'config'; |
| 2152 |
} |
| 2153 |
|
| 2154 |
// The other folders of wp-content (cache, languages, upgrade and |
| 2155 |
// whatever else is there), which belong to no plugin and no theme and |
| 2156 |
// had no turn. Before the plugins and the themes, which on a large |
| 2157 |
// site can use up the whole scan, and with a share of the time of |
| 2158 |
// their own, so a cache of a hundred thousand files cannot do the |
| 2159 |
// same to them. |
| 2160 |
$content_results = null; |
| 2161 |
if ( ! empty( $options['scan_plugins'] ) && ! $this->is_time_exceeded() ) { |
| 2162 |
$content_results = $this->scan_content_folders(); |
| 2163 |
$results['suspicious'] = array_merge( $results['suspicious'], $content_results['suspicious'] ); |
| 2164 |
$results['extra'] = array_merge( $results['extra'], $content_results['extra'] ); |
| 2165 |
$results['errors'] = array_merge( $results['errors'], $content_results['errors'] ); |
| 2166 |
} elseif ( ! empty( $options['scan_plugins'] ) ) { |
| 2167 |
$not_checked['areas'][] = 'content'; |
| 2168 |
} |
| 2169 |
|
| 2170 |
// Scan plugins |
| 2171 |
if ( ! empty( $options['scan_plugins'] ) && ! $this->is_time_exceeded() ) { |
| 2172 |
$plugin_results = $this->scan_plugins(); |
| 2173 |
$results = $this->merge_results( $results, $plugin_results ); |
| 2174 |
|
| 2175 |
$not_checked['plugins'] = $plugin_results['not_checked'] ?? array(); |
| 2176 |
$checked_folders = $plugin_results['checked'] ?? array(); |
| 2177 |
|
| 2178 |
if ( empty( $plugin_results['unlisted_checked'] ) ) { |
| 2179 |
$not_checked['areas'][] = 'unlisted'; |
| 2180 |
} |
| 2181 |
} elseif ( ! empty( $options['scan_plugins'] ) ) { |
| 2182 |
$not_checked['areas'][] = 'plugins'; |
| 2183 |
$not_checked['areas'][] = 'unlisted'; |
| 2184 |
} |
| 2185 |
|
| 2186 |
// Scan themes |
| 2187 |
if ( ! empty( $options['scan_themes'] ) && ! $this->is_time_exceeded() ) { |
| 2188 |
$theme_results = $this->scan_themes(); |
| 2189 |
$results = $this->merge_results( $results, $theme_results ); |
| 2190 |
|
| 2191 |
$not_checked['themes'] = $theme_results['not_checked'] ?? array(); |
| 2192 |
$checked_folders = array_merge( $checked_folders, $theme_results['checked'] ?? array() ); |
| 2193 |
} elseif ( ! empty( $options['scan_themes'] ) ) { |
| 2194 |
$not_checked['areas'][] = 'themes'; |
| 2195 |
} |
| 2196 |
|
| 2197 |
// What could only be searched for hidden code: plugins and themes |
| 2198 |
// first, as they are found, and the folders of wp-content after them. |
| 2199 |
if ( null !== $content_results ) { |
| 2200 |
$results['unverified'] = array_values( array_unique( array_merge( $results['unverified'] ?? array(), $content_results['unverified'] ) ) ); |
| 2201 |
} |
| 2202 |
|
| 2203 |
// Mark as incomplete if time was exceeded |
| 2204 |
if ( $this->is_time_exceeded() ) { |
| 2205 |
$results['incomplete'] = true; |
| 2206 |
$results['errors'][] = __( 'Scan was incomplete due to time limit. Results may be partial.', 'vigilante' ); |
| 2207 |
} |
| 2208 |
|
| 2209 |
// Stored after the last merge: merge_results() keeps only the keys it |
| 2210 |
// knows. |
| 2211 |
$not_checked['areas'] = array_values( array_unique( $not_checked['areas'] ) ); |
| 2212 |
$results['not_checked'] = $not_checked; |
| 2213 |
|
| 2214 |
// Only with plugins or themes left out: a scan that reached them all |
| 2215 |
// has nothing to make room for. |
| 2216 |
if ( ! empty( $not_checked['plugins'] ) || ! empty( $not_checked['themes'] ) || array_intersect( array( 'plugins', 'themes' ), $not_checked['areas'] ) ) { |
| 2217 |
$results['checked_folders'] = array_values( array_unique( $checked_folders ) ); |
| 2218 |
} |
| 2219 |
|
| 2220 |
// Filter out ignored files from all result categories |
| 2221 |
$results['modified'] = $this->filter_ignored( $results['modified'] ); |
| 2222 |
$results['suspicious'] = $this->filter_ignored( $results['suspicious'] ); |
| 2223 |
$results['extra'] = $this->filter_ignored( $results['extra'] ); |
| 2224 |
|
| 2225 |
$results['scan_time'] = round( microtime( true ) - $this->scan_start_time, 2 ); |
| 2226 |
|
| 2227 |
// Log the scan only if activity_log is available |
| 2228 |
if ( $this->activity_log ) { |
| 2229 |
$has_issues = ! empty( $results['modified'] ) || ! empty( $results['suspicious'] ) || ! empty( $results['extra'] ); |
| 2230 |
$severity = $has_issues ? 'warning' : 'info'; |
| 2231 |
|
| 2232 |
/* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */ |
| 2233 |
$log_text = __( 'File integrity scan completed: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' ); |
| 2234 |
|
| 2235 |
if ( $results['incomplete'] ) { |
| 2236 |
/* translators: 1: Scanned count, 2: Modified count, 3: Suspicious count, 4: Extra files count */ |
| 2237 |
$log_text = __( 'File integrity scan ran out of time, so its result is partial: %1$d files scanned, %2$d modified, %3$d suspicious, %4$d extra', 'vigilante' ); |
| 2238 |
} |
| 2239 |
|
| 2240 |
$this->activity_log->log( |
| 2241 |
'file', |
| 2242 |
'integrity_scan', |
| 2243 |
sprintf( |
| 2244 |
$log_text, |
| 2245 |
$results['scanned'], |
| 2246 |
count( $results['modified'] ), |
| 2247 |
count( $results['suspicious'] ), |
| 2248 |
count( $results['extra'] ) |
| 2249 |
), |
| 2250 |
array( |
| 2251 |
'scanned' => $results['scanned'], |
| 2252 |
'modified' => count( $results['modified'] ), |
| 2253 |
'suspicious' => count( $results['suspicious'] ), |
| 2254 |
'extra' => count( $results['extra'] ), |
| 2255 |
'scan_time' => $results['scan_time'], |
| 2256 |
'incomplete' => $results['incomplete'], |
| 2257 |
), |
| 2258 |
$severity |
| 2259 |
); |
| 2260 |
} |
| 2261 |
|
| 2262 |
// Closed plugins check: queries the wp.org repository for the closure status |
| 2263 |
// of every installed plugin slug. Independent of the file-level scan_* toggles |
| 2264 |
// (gated by its own `check_closed_plugins` toggle in Scan Scope). Runs BEFORE |
| 2265 |
// the notification call so closed plugins are folded into the scan email |
| 2266 |
// (instead of triggering a separate one-shot). Quick (~10 s for 50 plugins). |
| 2267 |
// |
| 2268 |
// suppress_email=true: this entry point is the file integrity scan; the |
| 2269 |
// daily plugin-status cron passes suppress_email=false so urgent closures |
| 2270 |
// still produce an immediate alert when the file scan is on a weekly schedule. |
| 2271 |
if ( ! empty( $options['check_closed_plugins'] ) ) { |
| 2272 |
if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) { |
| 2273 |
require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php'; |
| 2274 |
} |
| 2275 |
$closed_checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log ); |
| 2276 |
$closed_checker->check_all_plugins( true, true ); |
| 2277 |
} |
| 2278 |
|
| 2279 |
// Send email notification based on notify_level (now also includes closed |
| 2280 |
// plugins picked up just above). |
| 2281 |
$this->maybe_send_notification( $results ); |
| 2282 |
|
| 2283 |
return $results; |
| 2284 |
} |
| 2285 |
|
| 2286 |
/** |
| 2287 |
* Scan WordPress core files |
| 2288 |
* |
| 2289 |
* @return array Scan results. |
| 2290 |
*/ |
| 2291 |
private function scan_core_files() { |
| 2292 |
$results = array( |
| 2293 |
'scanned' => 0, |
| 2294 |
'ok' => 0, |
| 2295 |
'modified' => array(), |
| 2296 |
'missing' => array(), |
| 2297 |
'errors' => array(), |
| 2298 |
); |
| 2299 |
|
| 2300 |
// Get official checksums from WordPress.org |
| 2301 |
$this->core_checksums_are_english = false; |
| 2302 |
$checksums = $this->get_core_checksums(); |
| 2303 |
|
| 2304 |
if ( is_wp_error( $checksums ) ) { |
| 2305 |
$results['errors'][] = $checksums->get_error_message(); |
| 2306 |
return $results; |
| 2307 |
} |
| 2308 |
|
| 2309 |
// The plugins and themes that get a turn of their own in this scan. |
| 2310 |
$own_turn = array( |
| 2311 |
'plugins' => array(), |
| 2312 |
'themes' => array(), |
| 2313 |
); |
| 2314 |
|
| 2315 |
if ( ! empty( $this->options['scan_plugins'] ) ) { |
| 2316 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 2317 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 2318 |
} |
| 2319 |
|
| 2320 |
foreach ( array_keys( get_plugins() ) as $plugin_file ) { |
| 2321 |
$slash = strpos( $plugin_file, '/' ); |
| 2322 |
|
| 2323 |
if ( false !== $slash ) { |
| 2324 |
$own_turn['plugins'][ substr( $plugin_file, 0, $slash ) ] = true; |
| 2325 |
} |
| 2326 |
} |
| 2327 |
} |
| 2328 |
|
| 2329 |
if ( ! empty( $this->options['scan_themes'] ) ) { |
| 2330 |
foreach ( array_keys( wp_get_themes() ) as $stylesheet ) { |
| 2331 |
$stylesheet = (string) $stylesheet; |
| 2332 |
$slash = strpos( $stylesheet, '/' ); |
| 2333 |
|
| 2334 |
$own_turn['themes'][ false === $slash ? $stylesheet : substr( $stylesheet, 0, $slash ) ] = true; |
| 2335 |
} |
| 2336 |
} |
| 2337 |
|
| 2338 |
foreach ( $checksums as $file => $expected_hash ) { |
| 2339 |
// Check time limit |
| 2340 |
if ( $this->is_time_exceeded() ) { |
| 2341 |
break; |
| 2342 |
} |
| 2343 |
|
| 2344 |
$file_path = ABSPATH . $file; |
| 2345 |
|
| 2346 |
// Skip excluded paths |
| 2347 |
if ( $this->is_path_excluded( $file_path ) ) { |
| 2348 |
continue; |
| 2349 |
} |
| 2350 |
|
| 2351 |
// Skip excluded extensions |
| 2352 |
if ( $this->is_extension_excluded( $file_path ) ) { |
| 2353 |
continue; |
| 2354 |
} |
| 2355 |
|
| 2356 |
// Skip known false positives (e.g. readme.html, license.txt) |
| 2357 |
if ( in_array( $file, $this->core_known_false_positives, true ) ) { |
| 2358 |
continue; |
| 2359 |
} |
| 2360 |
|
| 2361 |
// Skip translations that travel inside the localized core ZIP but do |
| 2362 |
// not belong to core. WordPress.org's localized checksum manifest |
| 2363 |
// lists Akismet and the default themes' language files (8 entries on |
| 2364 |
// every non-en_US locale, none on en_US), yet they are updated on the |
| 2365 |
// plugin and theme cycle and are absent from the core language pack. |
| 2366 |
// Deleting an unused plugin or theme, which this plugin's own audit |
| 2367 |
// recommends, otherwise left permanent "missing core file" findings. |
| 2368 |
if ( 0 === strpos( $file, 'wp-content/languages/plugins/' ) |
| 2369 |
|| 0 === strpos( $file, 'wp-content/languages/themes/' ) ) { |
| 2370 |
continue; |
| 2371 |
} |
| 2372 |
|
| 2373 |
// The bundled themes and plugins have their own turn. Akismet and |
| 2374 |
// the default themes are in this list at the version WordPress |
| 2375 |
// shipped, and they update on their own cycle: every one of those |
| 2376 |
// updates showed as a modified core file, and deleting one that is |
| 2377 |
// not in use as missing core files. The plugin and theme scans |
| 2378 |
// compare them with their own published copy, so they are left to |
| 2379 |
// those whenever those are on and have them in their list. |
| 2380 |
if ( preg_match( '#^wp-content/(plugins|themes)/([^/]+)/#', $file, $bundled ) ) { |
| 2381 |
// Deleted on purpose: nothing to compare and nothing missing. |
| 2382 |
if ( ! is_dir( ABSPATH . 'wp-content/' . $bundled[1] . '/' . $bundled[2] ) ) { |
| 2383 |
continue; |
| 2384 |
} |
| 2385 |
|
| 2386 |
// Its own scan compares it, but only what WordPress lists as a |
| 2387 |
// plugin or as a theme gets that turn. A folder that is there |
| 2388 |
// and is not listed (a plugin without its header, a theme |
| 2389 |
// without its style.css) stays with the core. |
| 2390 |
if ( isset( $own_turn[ $bundled[1] ][ $bundled[2] ] ) ) { |
| 2391 |
continue; |
| 2392 |
} |
| 2393 |
} |
| 2394 |
|
| 2395 |
$results['scanned']++; |
| 2396 |
|
| 2397 |
if ( ! file_exists( $file_path ) ) { |
| 2398 |
$results['missing'][] = array( |
| 2399 |
'file' => $file, |
| 2400 |
'type' => 'core', |
| 2401 |
); |
| 2402 |
continue; |
| 2403 |
} |
| 2404 |
|
| 2405 |
$actual_hash = md5_file( $file_path ); |
| 2406 |
|
| 2407 |
if ( $actual_hash !== $expected_hash && ! $this->matches_core_of_other_locale( $file, $actual_hash ) ) { |
| 2408 |
// The two files a translated package changes cannot be told |
| 2409 |
// from a modified one while only the English list exists. Said |
| 2410 |
// as what it is, a file that could not be verified, instead of |
| 2411 |
// as a modified core file. |
| 2412 |
if ( $this->core_checksums_are_english && in_array( $file, array( 'wp-includes/version.php', 'wp-config-sample.php' ), true ) ) { |
| 2413 |
$results['errors'][] = sprintf( |
| 2414 |
/* translators: %s: File name */ |
| 2415 |
__( '%s differs from the English package of WordPress, and WordPress.org has not published the checksums for the language of this site yet, so it could not be verified.', 'vigilante' ), |
| 2416 |
$file |
| 2417 |
); |
| 2418 |
continue; |
| 2419 |
} |
| 2420 |
|
| 2421 |
$results['modified'][] = array( |
| 2422 |
'file' => $file, |
| 2423 |
'type' => 'core', |
| 2424 |
'expected_hash' => $expected_hash, |
| 2425 |
'actual_hash' => $actual_hash, |
| 2426 |
); |
| 2427 |
} else { |
| 2428 |
$results['ok']++; |
| 2429 |
} |
| 2430 |
} |
| 2431 |
|
| 2432 |
$results['modified'] = $this->drop_stale_language_mismatches( $results['modified'], $results ); |
| 2433 |
|
| 2434 |
return $results; |
| 2435 |
} |
| 2436 |
|
| 2437 |
/** |
| 2438 |
* Drop language files that only mismatch because the manifest was stale |
| 2439 |
* |
| 2440 |
* wp.org rebuilds the checksum manifest every time GlotPress rebuilds a |
| 2441 |
* language pack, and that happens without the WordPress version moving. The |
| 2442 |
* cache key here is version plus locale, so it does not expire when that |
| 2443 |
* happens, and the site spends up to a day comparing today's translation |
| 2444 |
* files against yesterday's manifest. That is where the bursts of "modified |
| 2445 |
* core files" under wp-content/languages/ come from, and they are not |
| 2446 |
* modifications at all. |
| 2447 |
* |
| 2448 |
* So before reporting one, the manifest is fetched again bypassing the |
| 2449 |
* cache, once per scan, and whatever matches the fresh copy is dropped. |
| 2450 |
* Anything still mismatching is reported as before. |
| 2451 |
* |
| 2452 |
* @since 2.9.9 |
| 2453 |
* |
| 2454 |
* @param array $modified Entries flagged as modified. |
| 2455 |
* @param array $results Scan results, to move the recovered files to 'ok'. |
| 2456 |
* @return array Entries that are still modified. |
| 2457 |
*/ |
| 2458 |
private function drop_stale_language_mismatches( $modified, &$results ) { |
| 2459 |
if ( empty( $modified ) ) { |
| 2460 |
return $modified; |
| 2461 |
} |
| 2462 |
|
| 2463 |
$suspects = array(); |
| 2464 |
foreach ( $modified as $entry ) { |
| 2465 |
if ( 0 === strpos( $entry['file'], 'wp-content/languages/' ) ) { |
| 2466 |
$suspects[ $entry['file'] ] = true; |
| 2467 |
} |
| 2468 |
} |
| 2469 |
|
| 2470 |
if ( empty( $suspects ) ) { |
| 2471 |
return $modified; |
| 2472 |
} |
| 2473 |
|
| 2474 |
$fresh = $this->get_core_checksums( true ); |
| 2475 |
|
| 2476 |
if ( is_wp_error( $fresh ) || empty( $fresh ) ) { |
| 2477 |
return $modified; |
| 2478 |
} |
| 2479 |
|
| 2480 |
$kept = array(); |
| 2481 |
|
| 2482 |
foreach ( $modified as $entry ) { |
| 2483 |
$file = $entry['file']; |
| 2484 |
|
| 2485 |
if ( ! isset( $suspects[ $file ] ) || ! isset( $fresh[ $file ] ) ) { |
| 2486 |
$kept[] = $entry; |
| 2487 |
continue; |
| 2488 |
} |
| 2489 |
|
| 2490 |
if ( $this->hash_matches_published( ABSPATH . $file, $fresh[ $file ] ) ) { |
| 2491 |
$results['ok']++; |
| 2492 |
continue; |
| 2493 |
} |
| 2494 |
|
| 2495 |
$kept[] = $entry; |
| 2496 |
} |
| 2497 |
|
| 2498 |
return $kept; |
| 2499 |
} |
| 2500 |
|
| 2501 |
/** |
| 2502 |
* Scan WordPress root directory for non-core files |
| 2503 |
* |
| 2504 |
* Compares files in ABSPATH (non-recursive) against the official core |
| 2505 |
* checksums list. PHP files not in the core distribution are flagged as |
| 2506 |
* suspicious (common attack vector: info.php, shell.php, backdoors). |
| 2507 |
* Non-PHP files not in the known safe list are flagged as extra/additional. |
| 2508 |
* Dotfiles and known safe files (robots.txt, etc.) are skipped. |
| 2509 |
* |
| 2510 |
* @return array Array with 'suspicious' and 'extra' sub-arrays. |
| 2511 |
*/ |
| 2512 |
private function scan_root_files() { |
| 2513 |
$found = array( |
| 2514 |
'suspicious' => array(), |
| 2515 |
'extra' => array(), |
| 2516 |
); |
| 2517 |
|
| 2518 |
// Get core checksums to know which root files are legitimate |
| 2519 |
$checksums = $this->get_core_checksums(); |
| 2520 |
if ( is_wp_error( $checksums ) ) { |
| 2521 |
return $found; |
| 2522 |
} |
| 2523 |
|
| 2524 |
// Build list of known core root files from checksums (only root-level, no directory prefix) |
| 2525 |
$core_root_files = array(); |
| 2526 |
foreach ( array_keys( $checksums ) as $file ) { |
| 2527 |
// Only root-level files (no directory separator) |
| 2528 |
if ( false === strpos( $file, '/' ) ) { |
| 2529 |
$core_root_files[] = $file; |
| 2530 |
} |
| 2531 |
} |
| 2532 |
|
| 2533 |
// Also add wp-config.php which is not in checksums but is core |
| 2534 |
$core_root_files[] = 'wp-config.php'; |
| 2535 |
|
| 2536 |
$php_extensions = self::php_extensions(); |
| 2537 |
|
| 2538 |
// Scan only direct children of ABSPATH (not recursive) |
| 2539 |
$root_path = untrailingslashit( ABSPATH ); |
| 2540 |
$handle = opendir( $root_path ); |
| 2541 |
|
| 2542 |
if ( ! $handle ) { |
| 2543 |
return $found; |
| 2544 |
} |
| 2545 |
|
| 2546 |
while ( false !== ( $entry = readdir( $handle ) ) ) { |
| 2547 |
if ( $this->is_time_exceeded() ) { |
| 2548 |
break; |
| 2549 |
} |
| 2550 |
|
| 2551 |
// Skip . and .. |
| 2552 |
if ( '.' === $entry || '..' === $entry ) { |
| 2553 |
continue; |
| 2554 |
} |
| 2555 |
|
| 2556 |
// PHP reads .user.ini and php.ini from the folder of the script, and |
| 2557 |
// either can make it load a file before every request: the same |
| 2558 |
// door as .htaccess, for servers that run PHP outside Apache. |
| 2559 |
if ( in_array( strtolower( $entry ), array( '.user.ini', 'php.ini' ), true ) ) { |
| 2560 |
$config = $this->is_path_excluded( $root_path . '/' . $entry ) ? null : self::server_config_finding( $root_path . '/' . $entry, $entry ); |
| 2561 |
|
| 2562 |
if ( $config ) { |
| 2563 |
$config['item']['type'] = 'php_config_in_root'; |
| 2564 |
$found['extra'][] = $config['item']; |
| 2565 |
} |
| 2566 |
continue; |
| 2567 |
} |
| 2568 |
|
| 2569 |
// Skip dotfiles (.htaccess, .env, etc.): handled by firewall protection |
| 2570 |
if ( 0 === strpos( $entry, '.' ) ) { |
| 2571 |
continue; |
| 2572 |
} |
| 2573 |
|
| 2574 |
$full_path = $root_path . '/' . $entry; |
| 2575 |
|
| 2576 |
// Skip directories — we only care about files in root |
| 2577 |
if ( is_dir( $full_path ) ) { |
| 2578 |
continue; |
| 2579 |
} |
| 2580 |
|
| 2581 |
// Skip if this is a known core file |
| 2582 |
if ( in_array( $entry, $core_root_files, true ) ) { |
| 2583 |
continue; |
| 2584 |
} |
| 2585 |
|
| 2586 |
// Skip excluded paths |
| 2587 |
if ( $this->is_path_excluded( $full_path ) ) { |
| 2588 |
continue; |
| 2589 |
} |
| 2590 |
|
| 2591 |
// Skip known safe non-PHP root files |
| 2592 |
if ( in_array( strtolower( $entry ), $this->known_safe_root_files, true ) ) { |
| 2593 |
continue; |
| 2594 |
} |
| 2595 |
|
| 2596 |
// Site verification files and sitemaps that are what their name says. |
| 2597 |
if ( self::is_harmless_root_file( $entry, $full_path ) ) { |
| 2598 |
continue; |
| 2599 |
} |
| 2600 |
|
| 2601 |
$extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) ); |
| 2602 |
|
| 2603 |
if ( in_array( $extension, $php_extensions, true ) ) { |
| 2604 |
// Check if this is a legacy WordPress core file (removed from newer versions) |
| 2605 |
if ( in_array( $entry, $this->legacy_core_root_files, true ) ) { |
| 2606 |
$found['extra'][] = array( |
| 2607 |
'file' => $entry, |
| 2608 |
'type' => 'legacy_core', |
| 2609 |
'reason' => __( 'Legacy WordPress core file, removed in newer versions. Safe to delete.', 'vigilante' ), |
| 2610 |
); |
| 2611 |
continue; |
| 2612 |
} |
| 2613 |
|
| 2614 |
// Silence-is-golden placeholders dropped here by some setups |
| 2615 |
// (e.g. WordPress installed in a subdirectory, or third-party tooling). |
| 2616 |
if ( $this->is_silence_golden_file( $full_path ) ) { |
| 2617 |
continue; |
| 2618 |
} |
| 2619 |
|
| 2620 |
// PHP file not in core = suspicious |
| 2621 |
$reason = __( 'Non-core PHP file in WordPress root directory', 'vigilante' ); |
| 2622 |
|
| 2623 |
// Scan content for specific patterns |
| 2624 |
$pattern = $this->search_file( $full_path ); |
| 2625 |
if ( $pattern ) { |
| 2626 |
/* translators: %s: Suspicious pattern found */ |
| 2627 |
$reason = sprintf( __( 'Non-core PHP in root with suspicious code: %s', 'vigilante' ), $pattern ); |
| 2628 |
} |
| 2629 |
|
| 2630 |
$found['suspicious'][] = array( |
| 2631 |
'file' => $entry, |
| 2632 |
'type' => 'php_in_root', |
| 2633 |
'reason' => $reason, |
| 2634 |
); |
| 2635 |
} else { |
| 2636 |
// A copy of the configuration, a database dump or an archive |
| 2637 |
// is not one more loose file: whoever guesses its name can |
| 2638 |
// download it. |
| 2639 |
if ( false !== stripos( $entry, 'wp-config' ) || preg_match( '/\.(?:sql|bak|old|orig|save|swp|dump|zip|gz|tgz|tar|7z|rar)$/i', $entry ) ) { |
| 2640 |
$found['extra'][] = array( |
| 2641 |
'file' => $entry, |
| 2642 |
'type' => 'backup_in_root', |
| 2643 |
'reason' => __( 'Backup or copy of a sensitive file in the site root, where it can be downloaded', 'vigilante' ), |
| 2644 |
); |
| 2645 |
continue; |
| 2646 |
} |
| 2647 |
|
| 2648 |
// Non-PHP, non-known-safe file = additional (informational) |
| 2649 |
$found['extra'][] = array( |
| 2650 |
'file' => $entry, |
| 2651 |
'type' => 'extra_root', |
| 2652 |
'reason' => __( 'Non-core file in WordPress root directory', 'vigilante' ), |
| 2653 |
); |
| 2654 |
} |
| 2655 |
} |
| 2656 |
|
| 2657 |
closedir( $handle ); |
| 2658 |
|
| 2659 |
return $found; |
| 2660 |
} |
| 2661 |
|
| 2662 |
/** |
| 2663 |
* The line of a .user.ini or php.ini that makes PHP load a file by itself |
| 2664 |
* |
| 2665 |
* @param string $path Absolute path of the file. |
| 2666 |
* @return string|null The line, an empty string when there is none, or |
| 2667 |
* null when the file is too large to be read, which |
| 2668 |
* the caller reports instead of taking for clean. |
| 2669 |
*/ |
| 2670 |
private static function php_ini_loads( $path ) { |
| 2671 |
// PHP runs as the same user: what it cannot read here it cannot apply. |
| 2672 |
if ( ! is_readable( $path ) ) { |
| 2673 |
return ''; |
| 2674 |
} |
| 2675 |
|
| 2676 |
if ( filesize( $path ) > MB_IN_BYTES ) { |
| 2677 |
return null; |
| 2678 |
} |
| 2679 |
|
| 2680 |
$content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to look at its directives; nothing is written. |
| 2681 |
|
| 2682 |
foreach ( preg_split( '/\r\n|\r|\n/', (string) $content ) as $line ) { |
| 2683 |
$line = trim( $line ); |
| 2684 |
|
| 2685 |
if ( '' === $line || ';' === $line[0] || '#' === $line[0] ) { |
| 2686 |
continue; |
| 2687 |
} |
| 2688 |
|
| 2689 |
if ( preg_match( '/^(auto_prepend_file|auto_append_file)\s*=\s*(.*)$/i', $line, $parts ) ) { |
| 2690 |
$value = trim( $parts[2], " \t\"'" ); |
| 2691 |
|
| 2692 |
if ( '' !== $value && 'none' !== strtolower( $value ) ) { |
| 2693 |
return strtolower( $parts[1] ) . ' = ' . substr( $value, 0, 200 ); |
| 2694 |
} |
| 2695 |
} |
| 2696 |
} |
| 2697 |
|
| 2698 |
return ''; |
| 2699 |
} |
| 2700 |
|
| 2701 |
/** |
| 2702 |
* What a .htaccess, .user.ini or php.ini outside uploads makes the server do |
| 2703 |
* |
| 2704 |
* Inside a plugin, a theme, wp-content or a WordPress folder, one of these |
| 2705 |
* files is a finding only when it makes the server run code or load a file. |
| 2706 |
* One that denies access, or does nothing of the kind, is left alone. |
| 2707 |
* |
| 2708 |
* @param string $file_path Absolute path. |
| 2709 |
* @param string $relative Path for display. |
| 2710 |
* @return array|null Array with 'category' and 'item', or null. |
| 2711 |
*/ |
| 2712 |
private static function server_config_finding( $file_path, $relative ) { |
| 2713 |
$item = array( |
| 2714 |
'file' => $relative, |
| 2715 |
'type' => 'server_config', |
| 2716 |
); |
| 2717 |
|
| 2718 |
if ( '.htaccess' === strtolower( basename( $file_path ) ) ) { |
| 2719 |
if ( ! is_readable( $file_path ) ) { |
| 2720 |
return null; |
| 2721 |
} |
| 2722 |
|
| 2723 |
if ( filesize( $file_path ) >= 65536 ) { |
| 2724 |
$item['reason'] = __( '.htaccess file that is too large to be read', 'vigilante' ); |
| 2725 |
|
| 2726 |
return array( |
| 2727 |
'category' => 'extra', |
| 2728 |
'item' => $item, |
| 2729 |
); |
| 2730 |
} |
| 2731 |
|
| 2732 |
$facts = self::read_htaccess_rules( file_get_contents( $file_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to look at its directives; nothing is written. |
| 2733 |
|
| 2734 |
if ( empty( $facts['runs'] ) ) { |
| 2735 |
return null; |
| 2736 |
} |
| 2737 |
|
| 2738 |
$item['reason'] = self::htaccess_run_reason( $facts ); |
| 2739 |
|
| 2740 |
return array( |
| 2741 |
'category' => 'suspicious', |
| 2742 |
'item' => $item, |
| 2743 |
); |
| 2744 |
} |
| 2745 |
|
| 2746 |
$loads = self::php_ini_loads( $file_path ); |
| 2747 |
|
| 2748 |
if ( '' === $loads ) { |
| 2749 |
return null; |
| 2750 |
} |
| 2751 |
|
| 2752 |
$item['reason'] = null === $loads |
| 2753 |
? __( 'PHP configuration file that is too large to be read', 'vigilante' ) |
| 2754 |
/* translators: %s: The line of the file that does it */ |
| 2755 |
: sprintf( __( 'PHP configuration file that loads a file on every request: %s', 'vigilante' ), $loads ); |
| 2756 |
|
| 2757 |
return array( |
| 2758 |
'category' => 'extra', |
| 2759 |
'item' => $item, |
| 2760 |
); |
| 2761 |
} |
| 2762 |
|
| 2763 |
/** |
| 2764 |
* Whether a loose file in the site root is a verification file or a sitemap |
| 2765 |
* |
| 2766 |
* By what it contains or by its kind, never by its name alone: a Google |
| 2767 |
* verification file is one line that repeats its own name, and a sitemap |
| 2768 |
* is XML, which no server runs. |
| 2769 |
* |
| 2770 |
* @param string $entry File name. |
| 2771 |
* @param string $full_path Absolute path. |
| 2772 |
* @return bool |
| 2773 |
*/ |
| 2774 |
private static function is_harmless_root_file( $entry, $full_path ) { |
| 2775 |
if ( preg_match( '/^[a-z0-9_-]*sitemap[a-z0-9_-]*\.xml(?:\.gz)?$/i', $entry ) ) { |
| 2776 |
return true; |
| 2777 |
} |
| 2778 |
|
| 2779 |
if ( preg_match( '/^google[0-9a-f]{16}\.html$/', $entry ) && is_readable( $full_path ) && filesize( $full_path ) < 200 ) { |
| 2780 |
$content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to compare it with its own name; nothing is written. |
| 2781 |
|
| 2782 |
return 'google-site-verification: ' . $entry === trim( (string) $content ); |
| 2783 |
} |
| 2784 |
|
| 2785 |
return false; |
| 2786 |
} |
| 2787 |
|
| 2788 |
/** |
| 2789 |
* Walk a folder and everything under it without stopping at what cannot be read |
| 2790 |
* |
| 2791 |
* A folder PHP cannot open used to end the whole walk in silence, which is |
| 2792 |
* all it took to blind a scan: one unreadable folder early in the order, |
| 2793 |
* and nothing after it was looked at. Now the walk goes past it, and the |
| 2794 |
* caller reports it (see unreadable_folder_error()). |
| 2795 |
* |
| 2796 |
* @param string $directory Folder to walk. |
| 2797 |
* @param callable|null $skip Given the path of a folder, whether to leave |
| 2798 |
* it and everything under it out of the walk. |
| 2799 |
* @return RecursiveIteratorIterator Folders included: the caller skips them. |
| 2800 |
*/ |
| 2801 |
private static function walk_folder( $directory, $skip = null ) { |
| 2802 |
$folder = new RecursiveDirectoryIterator( $directory, RecursiveDirectoryIterator::SKIP_DOTS ); |
| 2803 |
|
| 2804 |
if ( null !== $skip ) { |
| 2805 |
$folder = new RecursiveCallbackFilterIterator( |
| 2806 |
$folder, |
| 2807 |
static function ( $current ) use ( $skip ) { |
| 2808 |
return ! ( $current->isDir() && call_user_func( $skip, $current->getPathname() ) ); |
| 2809 |
} |
| 2810 |
); |
| 2811 |
} |
| 2812 |
|
| 2813 |
return new RecursiveIteratorIterator( |
| 2814 |
$folder, |
| 2815 |
RecursiveIteratorIterator::SELF_FIRST, |
| 2816 |
RecursiveIteratorIterator::CATCH_GET_CHILD |
| 2817 |
); |
| 2818 |
} |
| 2819 |
|
| 2820 |
/** |
| 2821 |
* What the scan says about a folder it could not read |
| 2822 |
* |
| 2823 |
* @param string $path Absolute path of the folder. |
| 2824 |
* @return string |
| 2825 |
*/ |
| 2826 |
private static function unreadable_folder_error( $path ) { |
| 2827 |
return sprintf( |
| 2828 |
/* translators: %s: Folder path */ |
| 2829 |
__( 'The folder %s could not be read, so what is inside it was not checked.', 'vigilante' ), |
| 2830 |
str_replace( ABSPATH, '', $path ) |
| 2831 |
); |
| 2832 |
} |
| 2833 |
|
| 2834 |
/** |
| 2835 |
* Extensions a server may be set up to run as PHP |
| 2836 |
* |
| 2837 |
* One list for uploads, the site root, the core folders and the plugin and |
| 2838 |
* theme folders. Each had its own until now, and none had them all. |
| 2839 |
* |
| 2840 |
* @return array |
| 2841 |
*/ |
| 2842 |
private static function php_extensions() { |
| 2843 |
return array( 'php', 'php2', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8', 'phtml', 'pht', 'phar', 'phps', 'inc' ); |
| 2844 |
} |
| 2845 |
|
| 2846 |
/** |
| 2847 |
* Look in wp-admin and wp-includes for files WordPress does not ship |
| 2848 |
* |
| 2849 |
* The comparison with the official checksums only visits the files the |
| 2850 |
* checksums name, so a file added to either folder was never looked at. A |
| 2851 |
* PHP file there that is not part of the installed version is reported as |
| 2852 |
* suspicious. A file that changes how the server runs code (.htaccess, |
| 2853 |
* .user.ini, php.ini) is reported as extra, or as suspicious when it |
| 2854 |
* makes the server run code. Anything else that is not code, such as an |
| 2855 |
* error log the host writes there, is left alone. |
| 2856 |
* |
| 2857 |
* @return array Array with 'suspicious', 'extra' and 'errors' sub-arrays. |
| 2858 |
*/ |
| 2859 |
private function scan_core_folders() { |
| 2860 |
$found = array( |
| 2861 |
'suspicious' => array(), |
| 2862 |
'extra' => array(), |
| 2863 |
'errors' => array(), |
| 2864 |
); |
| 2865 |
|
| 2866 |
$checksums = $this->get_core_checksums(); |
| 2867 |
if ( is_wp_error( $checksums ) ) { |
| 2868 |
return $found; |
| 2869 |
} |
| 2870 |
|
| 2871 |
$php_extensions = self::php_extensions(); |
| 2872 |
$config_files = array( '.htaccess', '.user.ini', 'php.ini' ); |
| 2873 |
|
| 2874 |
foreach ( array( 'wp-admin', 'wp-includes' ) as $folder ) { |
| 2875 |
$directory = ABSPATH . $folder; |
| 2876 |
|
| 2877 |
if ( ! is_dir( $directory ) ) { |
| 2878 |
continue; |
| 2879 |
} |
| 2880 |
|
| 2881 |
try { |
| 2882 |
foreach ( self::walk_folder( $directory ) as $file ) { |
| 2883 |
if ( $this->is_time_exceeded() ) { |
| 2884 |
break 2; |
| 2885 |
} |
| 2886 |
|
| 2887 |
$file_path = $file->getPathname(); |
| 2888 |
|
| 2889 |
if ( $file->isDir() ) { |
| 2890 |
if ( ! $file->isReadable() ) { |
| 2891 |
$found['errors'][] = self::unreadable_folder_error( $file_path ); |
| 2892 |
} |
| 2893 |
continue; |
| 2894 |
} |
| 2895 |
|
| 2896 |
$relative = $this->normalize_rel_path( str_replace( '\\', '/', str_replace( ABSPATH, '', $file_path ) ) ); |
| 2897 |
|
| 2898 |
if ( isset( $checksums[ $relative ] ) || $this->is_path_excluded( $file_path ) ) { |
| 2899 |
continue; |
| 2900 |
} |
| 2901 |
|
| 2902 |
$extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ); |
| 2903 |
|
| 2904 |
if ( in_array( $extension, $php_extensions, true ) ) { |
| 2905 |
// The placeholder some hosts and tools drop in every folder. |
| 2906 |
if ( $this->is_silence_golden_file( $file_path ) ) { |
| 2907 |
continue; |
| 2908 |
} |
| 2909 |
|
| 2910 |
// Translation files, on the installations old enough |
| 2911 |
// to keep them in wp-includes/languages: text and |
| 2912 |
// nothing else, which is checked and not assumed. |
| 2913 |
if ( self::is_inside_languages( $file_path ) && true === $this->is_literal_data( $file_path ) ) { |
| 2914 |
continue; |
| 2915 |
} |
| 2916 |
|
| 2917 |
/* translators: %s: Folder name, wp-admin or wp-includes */ |
| 2918 |
$reason = sprintf( __( 'File in %s that is not part of the installed WordPress version', 'vigilante' ), $folder ); |
| 2919 |
|
| 2920 |
$pattern = $this->search_file( $file_path ); |
| 2921 |
if ( $pattern ) { |
| 2922 |
/* translators: 1: Folder name, wp-admin or wp-includes, 2: Suspicious pattern found */ |
| 2923 |
$reason = sprintf( __( 'File in %1$s that is not part of WordPress, with suspicious code: %2$s', 'vigilante' ), $folder, $pattern ); |
| 2924 |
} |
| 2925 |
|
| 2926 |
$found['suspicious'][] = array( |
| 2927 |
'file' => $relative, |
| 2928 |
'type' => 'php_in_core', |
| 2929 |
'reason' => $reason, |
| 2930 |
); |
| 2931 |
} elseif ( in_array( strtolower( basename( $file_path ) ), $config_files, true ) ) { |
| 2932 |
$config = self::server_config_finding( $file_path, $relative ); |
| 2933 |
|
| 2934 |
if ( $config && 'suspicious' === $config['category'] ) { |
| 2935 |
$found['suspicious'][] = $config['item']; |
| 2936 |
continue; |
| 2937 |
} |
| 2938 |
|
| 2939 |
$found['extra'][] = array( |
| 2940 |
'file' => $relative, |
| 2941 |
'type' => 'config_in_core', |
| 2942 |
/* translators: %s: Folder name, wp-admin or wp-includes */ |
| 2943 |
'reason' => $config ? $config['item']['reason'] : sprintf( __( 'Server configuration file in %s', 'vigilante' ), $folder ), |
| 2944 |
); |
| 2945 |
} |
| 2946 |
} |
| 2947 |
} catch ( Exception $e ) { |
| 2948 |
$found['errors'][] = self::unreadable_folder_error( $directory ); |
| 2949 |
} |
| 2950 |
} |
| 2951 |
|
| 2952 |
return $found; |
| 2953 |
} |
| 2954 |
|
| 2955 |
/** |
| 2956 |
* Look for code where there is no official copy to compare with |
| 2957 |
* |
| 2958 |
* Must-use plugins, drop-ins, the loose PHP files of wp-content, of the |
| 2959 |
* plugins folder and of the themes folder, and the folders in those two |
| 2960 |
* that WordPress does not list as a plugin or as a theme, were not opened |
| 2961 |
* by any part of the scan, although WordPress loads some of them on every |
| 2962 |
* request and the rest can be reached by their address. They are searched |
| 2963 |
* the way a plugin from outside WordPress.org is, for code written the way |
| 2964 |
* malware hides itself. A loose PHP file or a folder with PHP that is |
| 2965 |
* neither a plugin, a theme nor a drop-in is listed as extra, and so is a |
| 2966 |
* .htaccess, .user.ini or php.ini there that makes the server run code or |
| 2967 |
* load a file. |
| 2968 |
* |
| 2969 |
* @return array Array with 'suspicious', 'extra', 'unverified' and 'errors' sub-arrays. |
| 2970 |
*/ |
| 2971 |
private function scan_unlisted_code() { |
| 2972 |
$found = array( |
| 2973 |
'suspicious' => array(), |
| 2974 |
'extra' => array(), |
| 2975 |
'unverified' => array(), |
| 2976 |
'errors' => array(), |
| 2977 |
); |
| 2978 |
|
| 2979 |
// Must-use plugins: the whole folder. |
| 2980 |
if ( defined( 'WPMU_PLUGIN_DIR' ) && is_dir( WPMU_PLUGIN_DIR ) ) { |
| 2981 |
$mu = $this->detect_extra_files( WPMU_PLUGIN_DIR, array(), 'plugin', __( 'Must-use plugins', 'vigilante' ), true ); |
| 2982 |
|
| 2983 |
$found['suspicious'] = array_merge( $found['suspicious'], $mu['suspicious'] ); |
| 2984 |
$found['extra'] = array_merge( $found['extra'], $mu['extra'] ); |
| 2985 |
$found['errors'] = array_merge( $found['errors'], $mu['errors'] ); |
| 2986 |
|
| 2987 |
if ( $mu['php_files'] > 0 ) { |
| 2988 |
$found['unverified'][] = __( 'Must-use plugins', 'vigilante' ); |
| 2989 |
} |
| 2990 |
} |
| 2991 |
|
| 2992 |
if ( ! function_exists( '_get_dropins' ) || ! function_exists( 'get_plugins' ) ) { |
| 2993 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 2994 |
} |
| 2995 |
|
| 2996 |
$php_extensions = self::php_extensions(); |
| 2997 |
$config_files = array( '.htaccess', '.user.ini', 'php.ini' ); |
| 2998 |
$dropins = array_keys( _get_dropins() ); |
| 2999 |
|
| 3000 |
// What WordPress lists as a plugin or as a theme, by the file or the |
| 3001 |
// folder it lives in. |
| 3002 |
$single_plugins = array(); |
| 3003 |
$plugin_folders = array(); |
| 3004 |
foreach ( get_plugins() as $plugin_file => $plugin_data ) { |
| 3005 |
if ( false === strpos( $plugin_file, '/' ) ) { |
| 3006 |
$single_plugins[ $plugin_file ] = $plugin_data['Name'] ?? $plugin_file; |
| 3007 |
} else { |
| 3008 |
$plugin_folders[ substr( $plugin_file, 0, strpos( $plugin_file, '/' ) ) ] = true; |
| 3009 |
} |
| 3010 |
} |
| 3011 |
|
| 3012 |
// The themes by where they really are, because there can be more |
| 3013 |
// than one folder of themes and the same name in two of them. |
| 3014 |
$theme_folders = array(); |
| 3015 |
foreach ( wp_get_themes() as $stylesheet => $theme ) { |
| 3016 |
$stylesheet = (string) $stylesheet; |
| 3017 |
$slash = strpos( $stylesheet, '/' ); |
| 3018 |
$real = realpath( untrailingslashit( $theme->get_theme_root() ) . '/' . ( false === $slash ? $stylesheet : substr( $stylesheet, 0, $slash ) ) ); |
| 3019 |
|
| 3020 |
if ( false !== $real ) { |
| 3021 |
$theme_folders[ wp_normalize_path( $real ) ] = true; |
| 3022 |
} |
| 3023 |
} |
| 3024 |
|
| 3025 |
$roots = array( |
| 3026 |
array( 'content', WP_CONTENT_DIR ), |
| 3027 |
array( 'plugins', WP_PLUGIN_DIR ), |
| 3028 |
); |
| 3029 |
|
| 3030 |
// Every folder themes are registered in, not only the usual one. One |
| 3031 |
// that is inside a plugin belongs to that plugin, and has its turn |
| 3032 |
// with it. |
| 3033 |
global $wp_theme_directories; |
| 3034 |
|
| 3035 |
$plugin_roots = array(); |
| 3036 |
foreach ( array( WP_PLUGIN_DIR, defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : '' ) as $plugin_root ) { |
| 3037 |
$real = '' === (string) $plugin_root ? false : realpath( $plugin_root ); |
| 3038 |
|
| 3039 |
if ( false !== $real ) { |
| 3040 |
$plugin_roots[ wp_normalize_path( $real ) ] = true; |
| 3041 |
} |
| 3042 |
} |
| 3043 |
|
| 3044 |
$theme_roots = array(); |
| 3045 |
foreach ( array_merge( array( get_theme_root() ), is_array( $wp_theme_directories ) ? $wp_theme_directories : array() ) as $theme_root ) { |
| 3046 |
$real = '' === (string) $theme_root ? false : realpath( $theme_root ); |
| 3047 |
$real = false === $real ? '' : wp_normalize_path( $real ); |
| 3048 |
|
| 3049 |
if ( '' === $real || isset( $theme_roots[ $real ] ) || self::is_inside_any( $real, $plugin_roots ) ) { |
| 3050 |
continue; |
| 3051 |
} |
| 3052 |
|
| 3053 |
$theme_roots[ $real ] = true; |
| 3054 |
$roots[] = array( 'themes', untrailingslashit( $theme_root ) ); |
| 3055 |
} |
| 3056 |
|
| 3057 |
foreach ( $roots as $root ) { |
| 3058 |
list( $kind, $directory ) = $root; |
| 3059 |
|
| 3060 |
$entries = is_dir( $directory ) ? scandir( $directory ) : false; |
| 3061 |
|
| 3062 |
if ( ! is_array( $entries ) ) { |
| 3063 |
continue; |
| 3064 |
} |
| 3065 |
|
| 3066 |
foreach ( $entries as $entry ) { |
| 3067 |
if ( '.' === $entry || '..' === $entry ) { |
| 3068 |
continue; |
| 3069 |
} |
| 3070 |
|
| 3071 |
if ( $this->is_time_exceeded() ) { |
| 3072 |
break 2; |
| 3073 |
} |
| 3074 |
|
| 3075 |
$path = $directory . '/' . $entry; |
| 3076 |
|
| 3077 |
if ( $this->is_path_excluded( $path ) ) { |
| 3078 |
continue; |
| 3079 |
} |
| 3080 |
|
| 3081 |
$relative = str_replace( ABSPATH, '', $path ); |
| 3082 |
|
| 3083 |
// A folder among the plugins or the themes that WordPress does |
| 3084 |
// not list as one: no scan had a turn for it. |
| 3085 |
if ( is_dir( $path ) ) { |
| 3086 |
$real = realpath( $path ); |
| 3087 |
$listed = 'plugins' === $kind ? isset( $plugin_folders[ $entry ] ) : ( 'themes' === $kind ? ( false !== $real && isset( $theme_folders[ wp_normalize_path( $real ) ] ) ) : true ); |
| 3088 |
|
| 3089 |
// A link is followed like any other folder, because what |
| 3090 |
// is behind it is reached by its address just the same, |
| 3091 |
// unless it leads back to wp-content or above it, which |
| 3092 |
// would be walking the site a second time. |
| 3093 |
if ( $listed || ( is_link( $path ) && self::leads_back( $path ) ) ) { |
| 3094 |
continue; |
| 3095 |
} |
| 3096 |
|
| 3097 |
$inside = $this->detect_extra_files( $path, array(), 'themes' === $kind ? 'theme' : 'plugin', $entry, true ); |
| 3098 |
|
| 3099 |
$found['suspicious'] = array_merge( $found['suspicious'], $inside['suspicious'] ); |
| 3100 |
$found['extra'] = array_merge( $found['extra'], $inside['extra'] ); |
| 3101 |
$found['errors'] = array_merge( $found['errors'], $inside['errors'] ); |
| 3102 |
|
| 3103 |
if ( $inside['php_files'] > 0 ) { |
| 3104 |
$found['extra'][] = array( |
| 3105 |
'file' => $relative, |
| 3106 |
'type' => 'php_in_content', |
| 3107 |
'reason' => 'themes' === $kind |
| 3108 |
? __( 'Folder with PHP files that WordPress does not list as a theme', 'vigilante' ) |
| 3109 |
: __( 'Folder with PHP files that WordPress does not list as a plugin', 'vigilante' ), |
| 3110 |
); |
| 3111 |
} |
| 3112 |
continue; |
| 3113 |
} |
| 3114 |
|
| 3115 |
if ( in_array( strtolower( $entry ), $config_files, true ) ) { |
| 3116 |
$config = self::server_config_finding( $path, $relative ); |
| 3117 |
|
| 3118 |
if ( $config ) { |
| 3119 |
$found[ $config['category'] ][] = $config['item']; |
| 3120 |
} |
| 3121 |
continue; |
| 3122 |
} |
| 3123 |
|
| 3124 |
$extension = strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) ); |
| 3125 |
|
| 3126 |
if ( ! in_array( $extension, $php_extensions, true ) || $this->is_silence_golden_file( $path ) ) { |
| 3127 |
continue; |
| 3128 |
} |
| 3129 |
|
| 3130 |
// hello.php comes with WordPress and is compared with it. |
| 3131 |
if ( 'plugins' === $kind && 'hello.php' === $entry ) { |
| 3132 |
continue; |
| 3133 |
} |
| 3134 |
|
| 3135 |
$pattern = $this->search_file( $path, true ); |
| 3136 |
|
| 3137 |
$known = ( 'plugins' === $kind && isset( $single_plugins[ $entry ] ) ) || ( 'content' === $kind && in_array( $entry, $dropins, true ) ); |
| 3138 |
|
| 3139 |
if ( $known ) { |
| 3140 |
$found['unverified'][] = 'plugins' === $kind ? $single_plugins[ $entry ] : __( 'Drop-ins', 'vigilante' ); |
| 3141 |
} |
| 3142 |
|
| 3143 |
if ( $pattern ) { |
| 3144 |
$found['suspicious'][] = array( |
| 3145 |
'file' => $relative, |
| 3146 |
'type' => 'suspicious_plugin', |
| 3147 |
/* translators: 1: Plugin or theme name, or the folder the file is in, 2: Suspicious pattern found */ |
| 3148 |
'reason' => sprintf( __( 'File in %1$s with suspicious code: %2$s', 'vigilante' ), dirname( $relative ), $pattern ), |
| 3149 |
); |
| 3150 |
} elseif ( ! $known ) { |
| 3151 |
$found['extra'][] = array( |
| 3152 |
'file' => $relative, |
| 3153 |
'type' => 'php_in_content', |
| 3154 |
'reason' => __( 'PHP file that is not a plugin, a theme or a WordPress drop-in', 'vigilante' ), |
| 3155 |
); |
| 3156 |
} elseif ( null === $pattern ) { |
| 3157 |
$found['extra'][] = $this->not_searched_item( $path, $relative ); |
| 3158 |
} |
| 3159 |
} |
| 3160 |
} |
| 3161 |
|
| 3162 |
$found['unverified'] = array_values( array_unique( $found['unverified'] ) ); |
| 3163 |
|
| 3164 |
return $found; |
| 3165 |
} |
| 3166 |
|
| 3167 |
/** |
| 3168 |
* Look in the folders of wp-content that belong to no plugin and no theme |
| 3169 |
* |
| 3170 |
* cache, languages, upgrade and whatever else is there were not opened by |
| 3171 |
* any part of the scan, and a PHP file in any of them can be reached by |
| 3172 |
* its address like one anywhere else. Every PHP file in them is searched |
| 3173 |
* for hidden code, the way a plugin from outside WordPress.org is, and a |
| 3174 |
* .htaccess, .user.ini or php.ini is reported when it makes the server run |
| 3175 |
* code or load a file. A folder with PHP files that could only be |
| 3176 |
* searched is named among what has no official copy. |
| 3177 |
* |
| 3178 |
* Translation files are the exception, because what they must hold is |
| 3179 |
* known: text. WordPress loads the .l10n.php files of the site language |
| 3180 |
* with include on every request |
| 3181 |
* (wp-includes/l10n/class-wp-translation-file-php.php:24), so one that |
| 3182 |
* holds anything else is reported whatever the code looks like, and any |
| 3183 |
* other PHP file among the translations is listed. A PHP file that holds |
| 3184 |
* literal data and nothing else cannot run anything, and is left alone |
| 3185 |
* wherever it is (see is_literal_data()). |
| 3186 |
* |
| 3187 |
* Plugins, themes, must-use plugins and uploads have their own scans and |
| 3188 |
* are skipped here. The folders whose name says cache go last, and the |
| 3189 |
* whole walk has a share of the scan time of its own, so a cache with a |
| 3190 |
* hundred thousand files costs that share and no more. |
| 3191 |
* |
| 3192 |
* @return array Array with 'suspicious', 'extra', 'unverified' and 'errors' sub-arrays. |
| 3193 |
*/ |
| 3194 |
private function scan_content_folders() { |
| 3195 |
$found = array( |
| 3196 |
'suspicious' => array(), |
| 3197 |
'extra' => array(), |
| 3198 |
'unverified' => array(), |
| 3199 |
'errors' => array(), |
| 3200 |
); |
| 3201 |
|
| 3202 |
$entries = is_dir( WP_CONTENT_DIR ) ? scandir( WP_CONTENT_DIR ) : false; |
| 3203 |
|
| 3204 |
if ( ! is_array( $entries ) ) { |
| 3205 |
return $found; |
| 3206 |
} |
| 3207 |
|
| 3208 |
// The folders with a scan of their own, by where they really are. |
| 3209 |
global $wp_theme_directories; |
| 3210 |
|
| 3211 |
$uploads = wp_get_upload_dir(); |
| 3212 |
$uploads_real = empty( $uploads['basedir'] ) ? false : realpath( $uploads['basedir'] ); |
| 3213 |
$uploads_real = false === $uploads_real ? '' : wp_normalize_path( $uploads_real ); |
| 3214 |
$own = array(); |
| 3215 |
$roots = array_merge( |
| 3216 |
array( WP_PLUGIN_DIR, defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : '', get_theme_root() ), |
| 3217 |
is_array( $wp_theme_directories ) ? $wp_theme_directories : array() |
| 3218 |
); |
| 3219 |
|
| 3220 |
foreach ( $roots as $root ) { |
| 3221 |
$real = '' === (string) $root ? false : realpath( $root ); |
| 3222 |
|
| 3223 |
if ( false !== $real ) { |
| 3224 |
$own[ wp_normalize_path( $real ) ] = true; |
| 3225 |
} |
| 3226 |
} |
| 3227 |
|
| 3228 |
if ( '' !== $uploads_real ) { |
| 3229 |
$own[ $uploads_real ] = true; |
| 3230 |
} |
| 3231 |
|
| 3232 |
// On a site of a network that is not the main one, the uploads of the |
| 3233 |
// site live inside the uploads of the network, which are the main |
| 3234 |
// site's turn. |
| 3235 |
$network_uploads = is_multisite() && ! is_main_site() ? $uploads_real : ''; |
| 3236 |
|
| 3237 |
$folders = array(); |
| 3238 |
|
| 3239 |
foreach ( $entries as $entry ) { |
| 3240 |
$path = WP_CONTENT_DIR . '/' . $entry; |
| 3241 |
|
| 3242 |
if ( '.' === $entry || '..' === $entry || ! is_dir( $path ) ) { |
| 3243 |
continue; |
| 3244 |
} |
| 3245 |
|
| 3246 |
$real = realpath( $path ); |
| 3247 |
$real = false === $real ? '' : wp_normalize_path( $real ); |
| 3248 |
|
| 3249 |
// A folder with a scan of its own, or a link into one. A link to |
| 3250 |
// anywhere else is walked like the folder it stands for: deploy |
| 3251 |
// tools keep languages or a cache behind one, and what is there |
| 3252 |
// is reached by its address all the same. |
| 3253 |
if ( '' === $real || self::is_inside_any( $real, $own ) || ( is_link( $path ) && self::leads_back( $path ) ) ) { |
| 3254 |
continue; |
| 3255 |
} |
| 3256 |
|
| 3257 |
if ( '' !== $network_uploads && 0 === strpos( $network_uploads, $real . '/' ) ) { |
| 3258 |
continue; |
| 3259 |
} |
| 3260 |
|
| 3261 |
if ( $this->is_path_excluded( $path . '/.' ) ) { |
| 3262 |
continue; |
| 3263 |
} |
| 3264 |
|
| 3265 |
$folders[] = $entry; |
| 3266 |
} |
| 3267 |
|
| 3268 |
usort( |
| 3269 |
$folders, |
| 3270 |
static function ( $a, $b ) { |
| 3271 |
$a_cache = false !== stripos( $a, 'cache' ); |
| 3272 |
$b_cache = false !== stripos( $b, 'cache' ); |
| 3273 |
|
| 3274 |
return $a_cache === $b_cache ? strcmp( $a, $b ) : ( $a_cache ? 1 : -1 ); |
| 3275 |
} |
| 3276 |
); |
| 3277 |
|
| 3278 |
$php_extensions = self::php_extensions(); |
| 3279 |
$config_files = array( '.htaccess', '.user.ini', 'php.ini' ); |
| 3280 |
$deadline = microtime( true ) + $this->content_folders_time; |
| 3281 |
|
| 3282 |
// A folder with a scan of its own nested further down, or one the |
| 3283 |
// user left out, is not walked at all. |
| 3284 |
$skip = function ( $folder ) use ( $own ) { |
| 3285 |
$real = realpath( $folder ); |
| 3286 |
|
| 3287 |
return ( false !== $real && isset( $own[ wp_normalize_path( $real ) ] ) ) || $this->is_path_excluded( $folder . '/.' ); |
| 3288 |
}; |
| 3289 |
|
| 3290 |
foreach ( $folders as $entry ) { |
| 3291 |
$path = WP_CONTENT_DIR . '/' . $entry; |
| 3292 |
$php_files = 0; |
| 3293 |
|
| 3294 |
try { |
| 3295 |
foreach ( self::walk_folder( $path, $skip ) as $file ) { |
| 3296 |
if ( $this->is_time_exceeded() ) { |
| 3297 |
break 2; |
| 3298 |
} |
| 3299 |
|
| 3300 |
if ( microtime( true ) > $deadline ) { |
| 3301 |
$found['errors'][] = sprintf( |
| 3302 |
/* translators: %s: Folder path */ |
| 3303 |
__( 'The folders of wp-content outside plugins, themes and uploads were not checked completely: the time for them ran out in %s. If that folder only holds files that change all the time, such as a cache, add it to Excluded Paths.', 'vigilante' ), |
| 3304 |
str_replace( ABSPATH, '', $path ) |
| 3305 |
); |
| 3306 |
break 2; |
| 3307 |
} |
| 3308 |
|
| 3309 |
$file_path = $file->getPathname(); |
| 3310 |
|
| 3311 |
if ( $file->isDir() ) { |
| 3312 |
if ( ! $file->isReadable() ) { |
| 3313 |
$found['errors'][] = self::unreadable_folder_error( $file_path ); |
| 3314 |
} |
| 3315 |
continue; |
| 3316 |
} |
| 3317 |
|
| 3318 |
$basename = strtolower( basename( $file_path ) ); |
| 3319 |
|
| 3320 |
if ( in_array( $basename, $config_files, true ) ) { |
| 3321 |
$config = $this->is_path_excluded( $file_path ) ? null : self::server_config_finding( $file_path, str_replace( ABSPATH, '', $file_path ) ); |
| 3322 |
|
| 3323 |
if ( $config ) { |
| 3324 |
$found[ $config['category'] ][] = $config['item']; |
| 3325 |
} |
| 3326 |
continue; |
| 3327 |
} |
| 3328 |
|
| 3329 |
if ( ! in_array( strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ), $php_extensions, true ) ) { |
| 3330 |
continue; |
| 3331 |
} |
| 3332 |
|
| 3333 |
if ( $this->is_path_excluded( $file_path ) || $this->is_silence_golden_file( $file_path ) ) { |
| 3334 |
continue; |
| 3335 |
} |
| 3336 |
|
| 3337 |
$literal = $this->is_literal_data( $file_path ); |
| 3338 |
|
| 3339 |
if ( true === $literal ) { |
| 3340 |
continue; |
| 3341 |
} |
| 3342 |
|
| 3343 |
$relative = str_replace( ABSPATH, '', $file_path ); |
| 3344 |
|
| 3345 |
if ( false === $literal && '.l10n.php' === substr( $basename, -9 ) ) { |
| 3346 |
// Reported whatever the search finds, like a PHP file |
| 3347 |
// in uploads: WordPress loads it on every request, and |
| 3348 |
// no tool writes anything but the table of texts in |
| 3349 |
// it. What the search finds is said next to it. |
| 3350 |
$pattern = $this->search_file( $file_path, true ); |
| 3351 |
$reason = __( 'Translation file that holds something other than translations', 'vigilante' ); |
| 3352 |
|
| 3353 |
if ( $pattern ) { |
| 3354 |
/* translators: %s: Suspicious pattern found */ |
| 3355 |
$reason = sprintf( __( 'Translation file with suspicious code: %s', 'vigilante' ), $pattern ); |
| 3356 |
} |
| 3357 |
|
| 3358 |
$found['suspicious'][] = array( |
| 3359 |
'file' => $relative, |
| 3360 |
'type' => 'translation_with_code', |
| 3361 |
'reason' => $reason, |
| 3362 |
); |
| 3363 |
continue; |
| 3364 |
} |
| 3365 |
|
| 3366 |
$pattern = $this->search_file( $file_path, true ); |
| 3367 |
|
| 3368 |
if ( $pattern ) { |
| 3369 |
$found['suspicious'][] = array( |
| 3370 |
'file' => $relative, |
| 3371 |
'type' => 'suspicious_content', |
| 3372 |
/* translators: 1: Plugin or theme name, or the folder the file is in, 2: Suspicious pattern found */ |
| 3373 |
'reason' => sprintf( __( 'File in %1$s with suspicious code: %2$s', 'vigilante' ), str_replace( ABSPATH, '', $path ), $pattern ), |
| 3374 |
); |
| 3375 |
} elseif ( null === $pattern ) { |
| 3376 |
$found['extra'][] = $this->not_searched_item( $file_path, $relative ); |
| 3377 |
} elseif ( self::is_inside_languages( $file_path ) ) { |
| 3378 |
$found['extra'][] = array( |
| 3379 |
'file' => $relative, |
| 3380 |
'type' => 'php_in_content', |
| 3381 |
'reason' => __( 'PHP file among the translations that is not a translation file', 'vigilante' ), |
| 3382 |
); |
| 3383 |
} else { |
| 3384 |
$php_files++; |
| 3385 |
} |
| 3386 |
} |
| 3387 |
} catch ( Exception $e ) { |
| 3388 |
$found['errors'][] = self::unreadable_folder_error( $path ); |
| 3389 |
} |
| 3390 |
|
| 3391 |
if ( $php_files > 0 ) { |
| 3392 |
$found['unverified'][] = str_replace( ABSPATH, '', $path ); |
| 3393 |
} |
| 3394 |
} |
| 3395 |
|
| 3396 |
return $found; |
| 3397 |
} |
| 3398 |
|
| 3399 |
/** |
| 3400 |
* Whether a real path is one of a set of folders or lies inside one of them |
| 3401 |
* |
| 3402 |
* @param string $real Real path, with forward slashes. |
| 3403 |
* @param array $folders Real paths with forward slashes, as keys. |
| 3404 |
* @return bool |
| 3405 |
*/ |
| 3406 |
private static function is_inside_any( $real, $folders ) { |
| 3407 |
foreach ( array_keys( $folders ) as $folder ) { |
| 3408 |
if ( $real === $folder || 0 === strpos( $real, $folder . '/' ) ) { |
| 3409 |
return true; |
| 3410 |
} |
| 3411 |
} |
| 3412 |
|
| 3413 |
return false; |
| 3414 |
} |
| 3415 |
|
| 3416 |
/** |
| 3417 |
* Whether a link leads to wp-content itself or to a folder above it |
| 3418 |
* |
| 3419 |
* @param string $path Path of the link. |
| 3420 |
* @return bool True also when where it leads cannot be worked out. |
| 3421 |
*/ |
| 3422 |
private static function leads_back( $path ) { |
| 3423 |
$real = realpath( $path ); |
| 3424 |
$content = realpath( WP_CONTENT_DIR ); |
| 3425 |
|
| 3426 |
if ( false === $real || false === $content ) { |
| 3427 |
return true; |
| 3428 |
} |
| 3429 |
|
| 3430 |
return 0 === strpos( wp_normalize_path( $content ) . '/', wp_normalize_path( $real ) . '/' ); |
| 3431 |
} |
| 3432 |
|
| 3433 |
/** |
| 3434 |
* Whether a path is inside the folder WordPress keeps its translations in |
| 3435 |
* |
| 3436 |
* @param string $path Absolute path. |
| 3437 |
* @return bool |
| 3438 |
*/ |
| 3439 |
private static function is_inside_languages( $path ) { |
| 3440 |
if ( ! defined( 'WP_LANG_DIR' ) ) { |
| 3441 |
return false; |
| 3442 |
} |
| 3443 |
|
| 3444 |
return 0 === strpos( wp_normalize_path( $path ), untrailingslashit( wp_normalize_path( WP_LANG_DIR ) ) . '/' ); |
| 3445 |
} |
| 3446 |
|
| 3447 |
/** |
| 3448 |
* Whether a PHP file holds literal data and nothing else |
| 3449 |
* |
| 3450 |
* A file such as "<?php return [ 'a' => 'b' ];" cannot run anything. It is |
| 3451 |
* how WordPress, GlotPress, WP-CLI and Loco Translate write translation |
| 3452 |
* files, and how plugins keep small tables. The file is read one piece at |
| 3453 |
* a time, and every piece has to be a quoted string with nothing |
| 3454 |
* interpolated in it, a number, true, false, null, return, an array |
| 3455 |
* bracket, a comma, "=>", a dot or a semicolon. A parenthesis is |
| 3456 |
* accepted only as part of "array(" and to close it, so |
| 3457 |
* nothing can be called, and with those pieces there is no way to name a |
| 3458 |
* variable, a function, a class or a file. |
| 3459 |
* |
| 3460 |
* Anything else, a comment included, and the answer is no. Saying no to a |
| 3461 |
* harmless file only means it is searched like any other; saying yes to |
| 3462 |
* a file with code would hide it. So this reads less than PHP does, on |
| 3463 |
* purpose. Each piece is also one PHP would cut the same way: a minus |
| 3464 |
* sign only in front of a number and a dot never in front of "=", or |
| 3465 |
* "-=>" and ".=>" would be read here as an arrow and by PHP as an |
| 3466 |
* assignment. What it accepts is checked against PHP's own reading of |
| 3467 |
* the same file, at random, in the release checks. |
| 3468 |
* |
| 3469 |
* @param string $file_path Absolute path. |
| 3470 |
* @return bool|null True when it holds only data, false when it holds |
| 3471 |
* anything else, null when it could not be read whole. |
| 3472 |
*/ |
| 3473 |
private function is_literal_data( $file_path ) { |
| 3474 |
if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) { |
| 3475 |
return null; |
| 3476 |
} |
| 3477 |
|
| 3478 |
// With another source encoding set (zend.multibyte), a string does |
| 3479 |
// not end for PHP where it ends byte by byte: nothing is vouched for. |
| 3480 |
if ( ini_get( 'zend.multibyte' ) ) { |
| 3481 |
return null; |
| 3482 |
} |
| 3483 |
|
| 3484 |
$size = filesize( $file_path ); |
| 3485 |
|
| 3486 |
if ( false === $size || $size > self::MAX_SEARCHED_BYTES ) { |
| 3487 |
return null; |
| 3488 |
} |
| 3489 |
|
| 3490 |
$content = file_get_contents( $file_path, false, null, 0, self::MAX_SEARCHED_BYTES + 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to see whether it holds anything but data; nothing is written. |
| 3491 |
|
| 3492 |
if ( false === $content || strlen( $content ) > self::MAX_SEARCHED_BYTES ) { |
| 3493 |
return null; |
| 3494 |
} |
| 3495 |
|
| 3496 |
if ( 1 !== preg_match( '/\A<\?php[ \t\r\n]/', $content ) ) { |
| 3497 |
return false; |
| 3498 |
} |
| 3499 |
|
| 3500 |
$piece = '/\G(?:[ \t\r\n]++|\'[^\'\\\\]*+(?:\\\\.[^\'\\\\]*+)*+\'|"[^"\\\\$]*+(?:\\\\.[^"\\\\$]*+)*+"|-?+\d++(?:\.\d++)?+(?:e[+-]?+\d++)?+|=>|\.(?!=)|[\[\],;)]|(?:true|false|null|return)(?![a-z0-9_\x80-\xff])|array[ \t\r\n]*+\()/is'; |
| 3501 |
$length = strlen( $content ); |
| 3502 |
$offset = 6; |
| 3503 |
|
| 3504 |
while ( $offset < $length ) { |
| 3505 |
$match = array(); |
| 3506 |
$result = preg_match( $piece, $content, $match, 0, $offset ); |
| 3507 |
|
| 3508 |
if ( 1 !== $result ) { |
| 3509 |
return false === $result ? null : false; |
| 3510 |
} |
| 3511 |
|
| 3512 |
$offset += strlen( $match[0] ); |
| 3513 |
} |
| 3514 |
|
| 3515 |
return true; |
| 3516 |
} |
| 3517 |
|
| 3518 |
// ========================================================================= |
| 3519 |
// Critical config file baseline monitoring (wp-config.php, .htaccess) |
| 3520 |
// ========================================================================= |
| 3521 |
|
| 3522 |
/** |
| 3523 |
* Scan critical root files against stored baseline hashes |
| 3524 |
* |
| 3525 |
* Files like wp-config.php and .htaccess have no official WordPress.org |
| 3526 |
* checksum because their content is unique per installation. We maintain |
| 3527 |
* our own baseline hash and alert when the file changes outside of |
| 3528 |
* Vigilante's own modifications. |
| 3529 |
* |
| 3530 |
* On the first scan (no baseline stored yet) the baseline is created |
| 3531 |
* silently — there is nothing to compare against. |
| 3532 |
* |
| 3533 |
* @return array Array of modified file entries (same format as core modified). |
| 3534 |
*/ |
| 3535 |
/** |
| 3536 |
* Where a critical root file actually lives |
| 3537 |
* |
| 3538 |
* WordPress supports wp-config.php one directory above ABSPATH, guarded by |
| 3539 |
* wp-settings.php not being there: that is literally what the installed core |
| 3540 |
* does in wp-load.php, and it is a common hardening layout. Until 2.11.10 |
| 3541 |
* this module only looked inside ABSPATH, so on those installations |
| 3542 |
* wp-config.php was never added to the baseline, never compared and never |
| 3543 |
* mentioned: the module reported the site clean without having opened the |
| 3544 |
* one file it most needs to watch. A zero is justified, never assumed. The |
| 3545 |
* plugin already resolved both locations elsewhere |
| 3546 |
* (Vigilante_Database_Prefix::find_wpconfig_path()), just not here. Found by |
| 3547 |
* the file-by-file review of 2.11.10. |
| 3548 |
* |
| 3549 |
* @since 2.11.10 |
| 3550 |
* |
| 3551 |
* @param string $filename Name of the file, such as wp-config.php. |
| 3552 |
* @return string|false Absolute path, or false when it cannot be found. |
| 3553 |
*/ |
| 3554 |
private function critical_file_path( $filename ) { |
| 3555 |
$root = untrailingslashit( ABSPATH ); |
| 3556 |
$path = $root . '/' . $filename; |
| 3557 |
|
| 3558 |
if ( file_exists( $path ) ) { |
| 3559 |
return $path; |
| 3560 |
} |
| 3561 |
|
| 3562 |
if ( 'wp-config.php' === $filename ) { |
| 3563 |
$above = dirname( $root ) . '/wp-config.php'; |
| 3564 |
|
| 3565 |
// Suppressed like the core does in wp-load.php: the directory above |
| 3566 |
// the install is often outside open_basedir on shared hosting, and |
| 3567 |
// without the @ every scan emits a warning that can land in front of |
| 3568 |
// the JSON of an AJAX scan. |
| 3569 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The same @ the core uses for this same check in wp-load.php:52, where a wp-config.php one directory up is looked for: open_basedir makes file_exists() warn on a path outside it, and this must not print. |
| 3570 |
if ( @file_exists( $above ) && ! @file_exists( dirname( $root ) . '/wp-settings.php' ) ) { |
| 3571 |
return $above; |
| 3572 |
} |
| 3573 |
} |
| 3574 |
|
| 3575 |
return false; |
| 3576 |
} |
| 3577 |
|
| 3578 |
private function scan_critical_root_files() { |
| 3579 |
// Before reading anything: the scan is the only thing that reaches |
| 3580 |
// every site of a network on its own, through wp-cron and front-end |
| 3581 |
// traffic. Hooking the cleanup to admin_init alone left every subsite |
| 3582 |
// whose dashboard nobody opens with its old copy of wp-config.php, |
| 3583 |
// credentials included, for as long as nobody visited it. |
| 3584 |
$this->maybe_redact_stored_baseline(); |
| 3585 |
|
| 3586 |
// And claim the blocks already on disk before anything is compared, |
| 3587 |
// so the first scan after updating uses the rule that will stay. |
| 3588 |
$this->maybe_claim_owned_blocks(); |
| 3589 |
|
| 3590 |
$modified = array(); |
| 3591 |
$baseline = $this->get_critical_files_baseline(); |
| 3592 |
$baseline_changed = false; |
| 3593 |
|
| 3594 |
foreach ( $this->critical_root_files as $filename ) { |
| 3595 |
$full_path = $this->critical_file_path( $filename ); |
| 3596 |
|
| 3597 |
if ( false === $full_path ) { |
| 3598 |
continue; |
| 3599 |
} |
| 3600 |
|
| 3601 |
$content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 3602 |
if ( false === $content ) { |
| 3603 |
continue; |
| 3604 |
} |
| 3605 |
|
| 3606 |
$normalized = $this->normalize_critical_file( $filename, $content ); |
| 3607 |
$current_hash = md5( $normalized ); |
| 3608 |
|
| 3609 |
if ( ! isset( $baseline[ $filename ] ) ) { |
| 3610 |
// First time seeing this file — store baseline silently |
| 3611 |
$baseline[ $filename ] = array( |
| 3612 |
'hash' => $current_hash, |
| 3613 |
'size' => strlen( $content ), |
| 3614 |
'content' => $this->baseline_content( $filename, $normalized ), |
| 3615 |
'updated' => time(), |
| 3616 |
); |
| 3617 |
$baseline_changed = true; |
| 3618 |
continue; |
| 3619 |
} |
| 3620 |
|
| 3621 |
// Upgrade legacy baseline entries that lack content (pre-diff format) |
| 3622 |
if ( ! isset( $baseline[ $filename ]['content'] ) && $baseline[ $filename ]['hash'] === $current_hash ) { |
| 3623 |
$baseline[ $filename ]['content'] = $this->baseline_content( $filename, $normalized ); |
| 3624 |
$baseline_changed = true; |
| 3625 |
continue; |
| 3626 |
} |
| 3627 |
|
| 3628 |
/* |
| 3629 |
* The file has not changed, but the copy on record is not the copy |
| 3630 |
* that would be stored today: an entry written before 2.11.2 with |
| 3631 |
* the credentials in it, or one written before the redaction list |
| 3632 |
* grew. Rewrite it. |
| 3633 |
* |
| 3634 |
* Only when the hash matches, and that condition is the whole |
| 3635 |
* point: if the file HAD changed, this entry is the evidence of |
| 3636 |
* the change that the administrator still has to review, and |
| 3637 |
* rewriting it here would quietly destroy that evidence. |
| 3638 |
*/ |
| 3639 |
if ( $baseline[ $filename ]['hash'] === $current_hash ) { |
| 3640 |
$expected = $this->baseline_content( $filename, $normalized ); |
| 3641 |
|
| 3642 |
if ( $expected !== $baseline[ $filename ]['content'] ) { |
| 3643 |
$baseline[ $filename ]['content'] = $expected; |
| 3644 |
$baseline_changed = true; |
| 3645 |
} |
| 3646 |
|
| 3647 |
continue; |
| 3648 |
} |
| 3649 |
|
| 3650 |
/* |
| 3651 |
* Everything from here down is the changed file, and only that: the |
| 3652 |
* branch above returns on every matching hash, so there is no third |
| 3653 |
* case and no condition left to test. It used to be wrapped in an |
| 3654 |
* `if` repeating the opposite comparison, which read as if some |
| 3655 |
* other path could reach this point. It could not. Flagged by |
| 3656 |
* @calzbert, and worth the two lines it costs to say so. |
| 3657 |
* |
| 3658 |
* Both sides go through the same redaction, or every credential |
| 3659 |
* line would read as a change nobody made. |
| 3660 |
*/ |
| 3661 |
/* |
| 3662 |
* Where the shared files do not belong to this site, the lines are |
| 3663 |
* not computed at all. The diff is shown to whoever can open this |
| 3664 |
* site's screen, the administrator of a subsite included, and it |
| 3665 |
* was stored in the options of every subsite with the module on. |
| 3666 |
* Found by the audit of the admin surface for 2.11.8. The change is |
| 3667 |
* still reported, with both sizes, and the lines are read on the |
| 3668 |
* main site, where the change is approved. |
| 3669 |
*/ |
| 3670 |
if ( ! Vigilante_Settings::owns_shared_files() ) { |
| 3671 |
$diff = self::network_only_diff(); |
| 3672 |
} else { |
| 3673 |
$baseline_content = $baseline[ $filename ]['content'] ?? ''; |
| 3674 |
$current_content = $this->baseline_content( $filename, $normalized ); |
| 3675 |
$diff = ( '' !== $baseline_content && '' !== $current_content ) |
| 3676 |
? $this->compute_simple_diff( $baseline_content, $current_content ) |
| 3677 |
: array( 'added' => array(), 'removed' => array(), 'unavailable' => true ); |
| 3678 |
|
| 3679 |
// Say why there are no lines when today's copy could not be |
| 3680 |
// made safe, which approving does not change: the generic |
| 3681 |
// message talks about an old baseline. Cross review of 2.11.8. |
| 3682 |
if ( '' === $current_content && 'wp-config.php' === $filename ) { |
| 3683 |
$diff['redaction'] = true; |
| 3684 |
} |
| 3685 |
} |
| 3686 |
|
| 3687 |
$modified[] = array( |
| 3688 |
'file' => $filename, |
| 3689 |
'type' => 'critical_config', |
| 3690 |
'expected_hash' => $baseline[ $filename ]['hash'], |
| 3691 |
'actual_hash' => $current_hash, |
| 3692 |
'baseline_size' => $baseline[ $filename ]['size'], |
| 3693 |
'current_size' => strlen( $content ), |
| 3694 |
'diff' => $diff, |
| 3695 |
); |
| 3696 |
} |
| 3697 |
|
| 3698 |
if ( $baseline_changed ) { |
| 3699 |
$this->write_baseline( $baseline ); |
| 3700 |
} |
| 3701 |
|
| 3702 |
return $modified; |
| 3703 |
} |
| 3704 |
|
| 3705 |
/** |
| 3706 |
* Compute a simple line-based diff between two strings |
| 3707 |
* |
| 3708 |
* Returns added and removed lines with their original line numbers. |
| 3709 |
* Order is preserved. Uses a simple "line present in set" approach |
| 3710 |
* which works well for config files where most lines are unique. |
| 3711 |
* |
| 3712 |
* @param string $old Baseline content. |
| 3713 |
* @param string $new Current content. |
| 3714 |
* @return array Array with 'added' and 'removed' line entries. |
| 3715 |
*/ |
| 3716 |
private function compute_simple_diff( $old, $new ) { |
| 3717 |
$old_lines = explode( "\n", $old ); |
| 3718 |
$new_lines = explode( "\n", $new ); |
| 3719 |
|
| 3720 |
// Use hash sets for O(1) lookup. Use array_flip for cheap existence check. |
| 3721 |
$old_set = array_count_values( $old_lines ); |
| 3722 |
$new_set = array_count_values( $new_lines ); |
| 3723 |
|
| 3724 |
$removed = array(); |
| 3725 |
foreach ( $old_lines as $i => $line ) { |
| 3726 |
// Line only considered removed if baseline has more occurrences than current |
| 3727 |
if ( ! isset( $new_set[ $line ] ) || $new_set[ $line ] < ( $old_set[ $line ] ?? 0 ) ) { |
| 3728 |
$removed[] = array( |
| 3729 |
'line' => $i + 1, |
| 3730 |
'content' => $line, |
| 3731 |
); |
| 3732 |
// Decrement to handle duplicates correctly |
| 3733 |
if ( isset( $old_set[ $line ] ) ) { |
| 3734 |
$old_set[ $line ]--; |
| 3735 |
} |
| 3736 |
} |
| 3737 |
} |
| 3738 |
|
| 3739 |
// Reset for added detection |
| 3740 |
$old_set = array_count_values( $old_lines ); |
| 3741 |
$added = array(); |
| 3742 |
foreach ( $new_lines as $i => $line ) { |
| 3743 |
if ( ! isset( $old_set[ $line ] ) || $old_set[ $line ] < ( $new_set[ $line ] ?? 0 ) ) { |
| 3744 |
$added[] = array( |
| 3745 |
'line' => $i + 1, |
| 3746 |
'content' => $line, |
| 3747 |
); |
| 3748 |
if ( isset( $new_set[ $line ] ) ) { |
| 3749 |
$new_set[ $line ]--; |
| 3750 |
} |
| 3751 |
} |
| 3752 |
} |
| 3753 |
|
| 3754 |
return array( |
| 3755 |
'added' => $added, |
| 3756 |
'removed' => $removed, |
| 3757 |
'unavailable' => false, |
| 3758 |
); |
| 3759 |
} |
| 3760 |
|
| 3761 |
/** |
| 3762 |
* Normalize critical file content by removing Vigilante-managed blocks |
| 3763 |
* |
| 3764 |
* This ensures that changes made by Vigilante itself (security constants, |
| 3765 |
* htaccess rules) do not trigger false-positive modification alerts. |
| 3766 |
* Line endings are normalized to LF to prevent false positives from |
| 3767 |
* editors that change CRLF/LF. |
| 3768 |
* |
| 3769 |
* @param string $filename File name (e.g. 'wp-config.php'). |
| 3770 |
* @param string $content Raw file content. |
| 3771 |
* @return string Normalized content for hashing. |
| 3772 |
*/ |
| 3773 |
private function normalize_critical_file( $filename, $content, $drop_all_original = false ) { |
| 3774 |
// Normalize line endings first (CRLF and CR to LF) |
| 3775 |
$content = str_replace( array( "\r\n", "\r" ), "\n", $content ); |
| 3776 |
|
| 3777 |
/* |
| 3778 |
* Vigilant's own blocks are left out of the hash, so rewriting them is |
| 3779 |
* not reported as somebody else's change. Until 2.11.5 that covered |
| 3780 |
* everything between the markers, and every line carrying the |
| 3781 |
* [VIGILANTE_ORIGINAL] marker, whatever they contained. From 2.11.5 a |
| 3782 |
* block is left out only if it is exactly a block Vigilant wrote (see |
| 3783 |
* remember_owned_block()), and a marked line only while uncommenting it |
| 3784 |
* would still give a harmless define() (see is_vigilant_original_line()). |
| 3785 |
* |
| 3786 |
* Until the blocks already on disk have been claimed, the old rule |
| 3787 |
* applies unchanged. That is what keeps an update from changing the |
| 3788 |
* hash of a file nobody touched. |
| 3789 |
*/ |
| 3790 |
$claimed = $this->owned_blocks_claimed(); |
| 3791 |
|
| 3792 |
if ( 'wp-config.php' === $filename ) { |
| 3793 |
// Vigilante constants blocks (current and legacy) |
| 3794 |
foreach ( $this->wpconfig_markers as $markers ) { |
| 3795 |
$content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed ); |
| 3796 |
} |
| 3797 |
|
| 3798 |
// Lines commented out by Vigilante (original constants) |
| 3799 |
$content = preg_replace_callback( |
| 3800 |
'/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m', |
| 3801 |
function ( $line ) use ( $claimed, $drop_all_original ) { |
| 3802 |
// $drop_all_original reproduce la regla anterior a la 2.11.5 (quitar |
| 3803 |
// toda linea marcada) sobre los bloques de la regla nueva. Solo lo usa |
| 3804 |
// el re-base de la transicion, para decidir si la unica diferencia con |
| 3805 |
// el registro aprobado son estas lineas. Ver rebase_original_line_shift(). |
| 3806 |
return ( ! $claimed || $drop_all_original || $this->is_vigilant_original_line( $line[0] ) ) ? '' : $line[0]; |
| 3807 |
}, |
| 3808 |
$content |
| 3809 |
); |
| 3810 |
} elseif ( '.htaccess' === $filename ) { |
| 3811 |
// Vigilante htaccess blocks (firewall + security headers) |
| 3812 |
foreach ( $this->htaccess_markers as $markers ) { |
| 3813 |
$content = $this->strip_vigilant_blocks( $filename, $content, $markers, $claimed ); |
| 3814 |
} |
| 3815 |
} |
| 3816 |
|
| 3817 |
// Collapse multiple blank lines into one (blocks removal leaves gaps) |
| 3818 |
$content = preg_replace( '/\n{3,}/', "\n\n", $content ); |
| 3819 |
|
| 3820 |
return trim( $content ); |
| 3821 |
} |
| 3822 |
|
| 3823 |
/** |
| 3824 |
* Leave Vigilant's blocks for one pair of markers out of the content |
| 3825 |
* |
| 3826 |
* Before the claim, every block, as it always was. After it, only the blocks |
| 3827 |
* whose fingerprint was recorded when Vigilant wrote them. A block that does |
| 3828 |
* not match, edited or planted, stays in the content: it counts in the hash |
| 3829 |
* and shows up in the diff. |
| 3830 |
* |
| 3831 |
* The match runs from marker to marker and the removal also takes the |
| 3832 |
* whitespace after the block, exactly as before, so a file whose blocks are |
| 3833 |
* all Vigilant's normalizes to the same text under both rules. |
| 3834 |
* |
| 3835 |
* @since 2.11.5 |
| 3836 |
* |
| 3837 |
* @param string $filename 'wp-config.php' or '.htaccess'. |
| 3838 |
* @param string $content Content with normalized line endings. |
| 3839 |
* @param array $markers Start and end marker. |
| 3840 |
* @param bool $claimed Whether the claim has run. |
| 3841 |
* @return string |
| 3842 |
*/ |
| 3843 |
private function strip_vigilant_blocks( $filename, $content, $markers, $claimed ) { |
| 3844 |
$pattern = '/(' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . ')\s*/s'; |
| 3845 |
|
| 3846 |
if ( ! $claimed ) { |
| 3847 |
return preg_replace( $pattern, '', $content ); |
| 3848 |
} |
| 3849 |
|
| 3850 |
return preg_replace_callback( |
| 3851 |
$pattern, |
| 3852 |
function ( $match ) use ( $filename ) { |
| 3853 |
return self::is_owned_block( $filename, $match[1] ) ? '' : $match[0]; |
| 3854 |
}, |
| 3855 |
$content |
| 3856 |
); |
| 3857 |
} |
| 3858 |
|
| 3859 |
/** |
| 3860 |
* Whether a line carrying the original-constant marker is one Vigilant wrote |
| 3861 |
* |
| 3862 |
* comment_existing_constants() puts the marker in front of a define() of a |
| 3863 |
* constant it manages, and uncomment_original_constants() takes it away |
| 3864 |
* again whenever the constants are applied or removed, so whatever follows |
| 3865 |
* the marker gets to run some day. The line is left out of the hash only |
| 3866 |
* when there is nothing but indentation before the marker, nothing after it |
| 3867 |
* but a harmless define() and at most a line comment, and no PHP tag |
| 3868 |
* anywhere on it. That keeps it a comment today and harmless once |
| 3869 |
* uncommented. Anything else counts, and shows up in the diff. |
| 3870 |
* |
| 3871 |
* @since 2.11.5 |
| 3872 |
* |
| 3873 |
* @param string $line One line of wp-config.php. |
| 3874 |
* @return bool |
| 3875 |
*/ |
| 3876 |
/** |
| 3877 |
* Whether every marked line of a file can run nothing at all |
| 3878 |
* |
| 3879 |
* The question the re-base has to answer before adopting a file is whether |
| 3880 |
* the lines that carry the marker are only comments. Asking a stricter one |
| 3881 |
* was wrong in both directions: the first version of the guard used |
| 3882 |
* is_vigilant_original_line(), which also requires the commented define to |
| 3883 |
* match a known harmless shape, so it refused to re-base a perfectly inert |
| 3884 |
* line carrying an unusual define, which is exactly the case the re-base |
| 3885 |
* exists for, leaving the function unable to act at all. Found by the cross |
| 3886 |
* review of 2.11.10. |
| 3887 |
* |
| 3888 |
* The second version read one line at a time and reasoned that the marker |
| 3889 |
* begins with //, so a line with nothing but whitespace before it is wholly |
| 3890 |
* a comment. That is true only where PHP is already reading code, and the |
| 3891 |
* second cross review of 2.11.10 built three files where it is not, all of |
| 3892 |
* them valid PHP, all of them passing that test and all of them running or |
| 3893 |
* printing something: |
| 3894 |
* |
| 3895 |
* - the marked line placed BEFORE the opening <?php, so it is inline HTML |
| 3896 |
* that the server prints verbatim to the browser; |
| 3897 |
* - the same after a ?> that the file already had; |
| 3898 |
* - the marked line ending a block comment opened on an earlier line and |
| 3899 |
* opening another one at its end, with a statement in between, which |
| 3900 |
* runs like any other statement. |
| 3901 |
* |
| 3902 |
* So the file is read the way PHP reads it, not the way the line looks. A |
| 3903 |
* marked line is inert when every token touching it is a comment or |
| 3904 |
* whitespace, which answers the three at once: inline HTML is not a comment, |
| 3905 |
* and neither is a statement. The shape the guard was written for, code |
| 3906 |
* BEFORE the marker, is the same question from the other side. |
| 3907 |
* |
| 3908 |
* The three shapes are in the harness as cells X2, X3 and X4 of |
| 3909 |
* matriz-escondite-marcadores.sh, written out in full there. They are not |
| 3910 |
* written out here on purpose: a literal payload in a shipped file is |
| 3911 |
* signature surface for the scanners this plugin is read by, and a comment |
| 3912 |
* is a bad place to pay for it. |
| 3913 |
* |
| 3914 |
* @since 2.11.10 |
| 3915 |
* |
| 3916 |
* @param string $content Whole file content. |
| 3917 |
* @return bool True when no marked line can run or print anything. |
| 3918 |
*/ |
| 3919 |
/** |
| 3920 |
* The lines of a file that carry the original-value marker |
| 3921 |
* |
| 3922 |
* @since 2.11.10 |
| 3923 |
* |
| 3924 |
* @param string $content Whole file content, newlines already normalised. |
| 3925 |
* @return string[] |
| 3926 |
*/ |
| 3927 |
private function marked_lines_of( $content ) { |
| 3928 |
$out = array(); |
| 3929 |
|
| 3930 |
foreach ( explode( "\n", $content ) as $text ) { |
| 3931 |
if ( false !== strpos( $text, $this->wpconfig_original_marker ) ) { |
| 3932 |
$out[] = $text; |
| 3933 |
} |
| 3934 |
} |
| 3935 |
|
| 3936 |
return $out; |
| 3937 |
} |
| 3938 |
|
| 3939 |
/** |
| 3940 |
* Whether what a marked line carries would still be harmless uncommented |
| 3941 |
* |
| 3942 |
* Only the part after the marker matters: what comes before it is answered by |
| 3943 |
* the token pass, which refuses anything that is not comment or whitespace. |
| 3944 |
* Here the question is what comes BACK when uncomment_original_constants() |
| 3945 |
* removes the marker, so the body has to be a single define() and nothing |
| 3946 |
* else, with at most a trailing line comment. Deliberately says nothing about |
| 3947 |
* WHICH constant it is: asking that was the first version of this guard, and |
| 3948 |
* it refused every define it did not recognise, which is exactly the case the |
| 3949 |
* re-base exists for. |
| 3950 |
* |
| 3951 |
* @since 2.11.10 |
| 3952 |
* |
| 3953 |
* @param string $line One line carrying the marker. |
| 3954 |
* @return bool |
| 3955 |
*/ |
| 3956 |
private function marked_line_body_is_harmless( $line ) { |
| 3957 |
$at = strpos( $line, $this->wpconfig_original_marker ); |
| 3958 |
|
| 3959 |
if ( false === $at ) { |
| 3960 |
return true; |
| 3961 |
} |
| 3962 |
|
| 3963 |
$body = trim( substr( $line, $at + strlen( $this->wpconfig_original_marker ) ) ); |
| 3964 |
|
| 3965 |
if ( '' === $body ) { |
| 3966 |
return true; |
| 3967 |
} |
| 3968 |
|
| 3969 |
// Tokenised as PHP so the trailing comment, the strings and the nesting |
| 3970 |
// are read the way PHP reads them and not with a regular expression. |
| 3971 |
$tokens = @token_get_all( '<?php ' . $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A marked line can carry anything; a warning here must not be printed, and an unreadable body is refused below. |
| 3972 |
|
| 3973 |
if ( empty( $tokens ) ) { |
| 3974 |
return false; |
| 3975 |
} |
| 3976 |
|
| 3977 |
$statements = 0; |
| 3978 |
$depth = 0; |
| 3979 |
|
| 3980 |
foreach ( $tokens as $token ) { |
| 3981 |
$type = is_array( $token ) ? $token[0] : $token; |
| 3982 |
|
| 3983 |
if ( in_array( $type, array( T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { |
| 3984 |
continue; |
| 3985 |
} |
| 3986 |
|
| 3987 |
if ( '(' === $type ) { |
| 3988 |
$depth++; |
| 3989 |
continue; |
| 3990 |
} |
| 3991 |
|
| 3992 |
if ( ')' === $type ) { |
| 3993 |
$depth--; |
| 3994 |
continue; |
| 3995 |
} |
| 3996 |
|
| 3997 |
// A semicolon at the top level closes a statement. More than one, or |
| 3998 |
// anything after the first, means the line carries something else. |
| 3999 |
if ( ';' === $type && 0 === $depth ) { |
| 4000 |
$statements++; |
| 4001 |
continue; |
| 4002 |
} |
| 4003 |
|
| 4004 |
if ( $statements > 0 ) { |
| 4005 |
return false; |
| 4006 |
} |
| 4007 |
} |
| 4008 |
|
| 4009 |
return ( $statements <= 1 ); |
| 4010 |
} |
| 4011 |
|
| 4012 |
private function marked_lines_are_inert( $content ) { |
| 4013 |
$content = str_replace( "\r\n", "\n", (string) $content ); |
| 4014 |
$marker = $this->wpconfig_original_marker; |
| 4015 |
|
| 4016 |
if ( '' === $content || false === strpos( $content, $marker ) ) { |
| 4017 |
return true; |
| 4018 |
} |
| 4019 |
|
| 4020 |
$marked = array(); |
| 4021 |
|
| 4022 |
foreach ( explode( "\n", $content ) as $index => $text ) { |
| 4023 |
if ( false !== strpos( $text, $marker ) ) { |
| 4024 |
$marked[ $index + 1 ] = true; |
| 4025 |
} |
| 4026 |
} |
| 4027 |
|
| 4028 |
// Lenient on purpose (no TOKEN_PARSE): a tampered file still has to be |
| 4029 |
// read, and a file that cannot be tokenised is never adopted. |
| 4030 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The file read here may have been tampered with, which is the whole point, and PHP 8 emits a warning when it cannot tokenise: printing it would put a parse error on whatever page ran the scan. An unreadable file is refused four lines below. |
| 4031 |
$tokens = @token_get_all( $content ); |
| 4032 |
|
| 4033 |
if ( empty( $tokens ) ) { |
| 4034 |
return false; |
| 4035 |
} |
| 4036 |
|
| 4037 |
/* |
| 4038 |
* And the other half of the question, which the first token version left |
| 4039 |
* out: a marked line is a COMMENTED OUT value, and uncommenting it is what |
| 4040 |
* the feature exists for, so "runs nothing today" is not enough. Anything |
| 4041 |
* sharing the line after the define comes back with it. The shape is real |
| 4042 |
* and needs no attacker: comment_existing_constants() takes a define and |
| 4043 |
* everything on its line, so |
| 4044 |
* define( 'WP_DEBUG', false ); @ini_set( 'display_errors', 0 ); |
| 4045 |
* is commented whole, and re-basing it would adopt as approved something |
| 4046 |
* that runs the moment the value is restored. The old rule refused this |
| 4047 |
* too, but along with every define whose NAME it did not recognise, which |
| 4048 |
* is what left the function unable to act at all. Found by the third cross |
| 4049 |
* review of 2.11.10. |
| 4050 |
*/ |
| 4051 |
foreach ( $this->marked_lines_of( $content ) as $text ) { |
| 4052 |
if ( ! $this->marked_line_body_is_harmless( $text ) ) { |
| 4053 |
return false; |
| 4054 |
} |
| 4055 |
} |
| 4056 |
|
| 4057 |
$inocuos = array( T_COMMENT, T_DOC_COMMENT, T_WHITESPACE ); |
| 4058 |
$linea = 1; |
| 4059 |
|
| 4060 |
foreach ( $tokens as $token ) { |
| 4061 |
$texto = is_array( $token ) ? $token[1] : $token; |
| 4062 |
$tipo = is_array( $token ) ? $token[0] : null; |
| 4063 |
$saltos = substr_count( $texto, "\n" ); |
| 4064 |
$desde = $linea; |
| 4065 |
$hasta = $linea + $saltos; |
| 4066 |
|
| 4067 |
/* |
| 4068 |
* A token whose text ends in a newline puts nothing on the line that |
| 4069 |
* newline opens. Counting it would make the "<?php\n" of every file |
| 4070 |
* touch line 2 and refuse the legitimate case, which is what the |
| 4071 |
* first version of this did. |
| 4072 |
*/ |
| 4073 |
$ultima = ( $saltos > 0 && "\n" === substr( $texto, -1 ) ) ? $hasta - 1 : $hasta; |
| 4074 |
$linea = $hasta; |
| 4075 |
|
| 4076 |
if ( null !== $tipo && in_array( $tipo, $inocuos, true ) ) { |
| 4077 |
continue; |
| 4078 |
} |
| 4079 |
|
| 4080 |
for ( $l = $desde; $l <= $ultima; $l++ ) { |
| 4081 |
if ( isset( $marked[ $l ] ) ) { |
| 4082 |
return false; |
| 4083 |
} |
| 4084 |
} |
| 4085 |
} |
| 4086 |
|
| 4087 |
return true; |
| 4088 |
} |
| 4089 |
|
| 4090 |
private function is_vigilant_original_line( $line ) { |
| 4091 |
if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) { |
| 4092 |
return false; |
| 4093 |
} |
| 4094 |
|
| 4095 |
return 1 === preg_match( |
| 4096 |
'/^[ \t]*' . preg_quote( $this->wpconfig_original_marker, '/' ) . self::harmless_define_pattern() . '[ \t]*(?:(?:\/\/|#(?!\[)).*)?$/', |
| 4097 |
$line |
| 4098 |
); |
| 4099 |
} |
| 4100 |
|
| 4101 |
/** |
| 4102 |
* A define() that runs nothing but itself, as a regular expression fragment |
| 4103 |
* |
| 4104 |
* The name is one of the constants Vigilant has managed in any version. The |
| 4105 |
* value is made only of literals (true, false, null, a number, a quoted |
| 4106 |
* string with nothing to interpolate) and of ABSPATH, WP_CONTENT_DIR and |
| 4107 |
* __DIR__, which is what a debug log path is usually built from, joined |
| 4108 |
* with dots. No call, no variable, no backtick, no include. |
| 4109 |
* |
| 4110 |
* @since 2.11.5 |
| 4111 |
* |
| 4112 |
* @return string Pattern without delimiters. |
| 4113 |
*/ |
| 4114 |
private static function harmless_define_pattern() { |
| 4115 |
$names = 'DISALLOW_FILE_EDIT|DISALLOW_FILE_MODS|FORCE_SSL_ADMIN|FORCE_SSL_LOGIN|WP_DEBUG|WP_DEBUG_LOG|WP_DEBUG_DISPLAY|SCRIPT_DEBUG|DISABLE_WP_CRON' |
| 4116 |
. '|WP_POST_REVISIONS|AUTOSAVE_INTERVAL|EMPTY_TRASH_DAYS|WP_MEMORY_LIMIT|WP_MAX_MEMORY_LIMIT|WP_AUTO_UPDATE_CORE|CONCATENATE_SCRIPTS'; |
| 4117 |
|
| 4118 |
$value = '(?:(?i:true|false|null)|-?\d+|\'(?:[^\'\\\\]|\\\\.)*\'|"[^"\\\\$]*"|ABSPATH|WP_CONTENT_DIR|__DIR__)'; |
| 4119 |
|
| 4120 |
return 'define\s*\(\s*[\'"](?:' . $names . ')[\'"]\s*,\s*' . $value . '(?:\s*\.\s*' . $value . ')*\s*\)\s*;'; |
| 4121 |
} |
| 4122 |
|
| 4123 |
/** |
| 4124 |
* Whether a wp-config.php constants block can only be one Vigilant wrote |
| 4125 |
* |
| 4126 |
* Every version of generate_constants() has written the start marker on its |
| 4127 |
* own line, then comments, blank lines and define() calls, bare or wrapped |
| 4128 |
* in if ( ! defined() ), then the end marker on its own line. A block made |
| 4129 |
* only of those lines runs nothing but the defines, whatever version wrote |
| 4130 |
* it and whatever settings it was written with. One line of anything else, |
| 4131 |
* or a PHP tag on any line, and the block is not taken. |
| 4132 |
* |
| 4133 |
* @since 2.11.5 |
| 4134 |
* |
| 4135 |
* @param string $block Block from start marker to end marker, inclusive. |
| 4136 |
* @param array $markers Start and end marker. |
| 4137 |
* @return bool |
| 4138 |
*/ |
| 4139 |
private static function is_harmless_constants_block( $block, $markers ) { |
| 4140 |
$lines = explode( "\n", str_replace( array( "\r\n", "\r" ), "\n", (string) $block ) ); |
| 4141 |
|
| 4142 |
if ( count( $lines ) < 2 |
| 4143 |
|| rtrim( array_shift( $lines ), " \t" ) !== $markers[0] |
| 4144 |
|| ltrim( array_pop( $lines ), " \t" ) !== $markers[1] |
| 4145 |
) { |
| 4146 |
return false; |
| 4147 |
} |
| 4148 |
|
| 4149 |
$define = self::harmless_define_pattern(); |
| 4150 |
$guarded = '/^if\s*\(\s*!\s*defined\s*\(\s*[\'"][A-Z_]+[\'"]\s*\)\s*\)\s*\{\s*' . $define . '\s*\}$/'; |
| 4151 |
|
| 4152 |
foreach ( $lines as $line ) { |
| 4153 |
$line = trim( $line, " \t" ); |
| 4154 |
|
| 4155 |
if ( false !== strpos( $line, '<?' ) || false !== strpos( $line, '?>' ) ) { |
| 4156 |
return false; |
| 4157 |
} |
| 4158 |
|
| 4159 |
if ( '' === $line |
| 4160 |
|| 0 === strpos( $line, '//' ) |
| 4161 |
|| preg_match( '/^' . $define . '$/', $line ) |
| 4162 |
|| preg_match( $guarded, $line ) |
| 4163 |
) { |
| 4164 |
continue; |
| 4165 |
} |
| 4166 |
|
| 4167 |
return false; |
| 4168 |
} |
| 4169 |
|
| 4170 |
return true; |
| 4171 |
} |
| 4172 |
|
| 4173 |
/** |
| 4174 |
* Take ownership of the blocks already on disk, once |
| 4175 |
* |
| 4176 |
* Fingerprints are recorded when Vigilant writes a block, which leaves every |
| 4177 |
* block written before 2.11.5 without one. This records the blocks that can |
| 4178 |
* be recognised as Vigilant's without having seen them written: |
| 4179 |
* |
| 4180 |
* - A .htaccess block, when it is exactly what Vigilant would write today |
| 4181 |
* with the settings it has, the timestamp and the version apart. After an |
| 4182 |
* update, maybe_sync_server_files() rewrites those blocks on the next |
| 4183 |
* request, so the claim waits for it. Unless it has already failed: a |
| 4184 |
* block Vigilant cannot rewrite is not going to start matching, and |
| 4185 |
* waiting for it would keep the old rule for good. |
| 4186 |
* - A wp-config.php constants block, when every line in it is a comment, a |
| 4187 |
* blank line or a harmless define(). Nothing rewrites that block on an |
| 4188 |
* update and its format has changed five times, so comparing it with |
| 4189 |
* today's output would report every site that has not saved those |
| 4190 |
* settings since. A line that could run anything is never accepted. |
| 4191 |
* |
| 4192 |
* A block that is not recognised stays in the hash and is reported as a |
| 4193 |
* change, so the owner gets to look at it, and the activity log says why. |
| 4194 |
* Nothing in the stored baseline is rewritten. |
| 4195 |
* |
| 4196 |
* Only where the shared files belong, a single site or the main site of a |
| 4197 |
* network, because the expected blocks come from that site's settings. Until |
| 4198 |
* it has run, normalize_critical_file() keeps the old rule on every site. |
| 4199 |
* |
| 4200 |
* @since 2.11.5 |
| 4201 |
*/ |
| 4202 |
/** |
| 4203 |
* The admin_init entry point of the claim, which does ask for an administrator |
| 4204 |
* |
| 4205 |
* admin-ajax.php fires admin_init before it decides who is asking |
| 4206 |
* (wp-admin/admin-ajax.php:45), so without this an anonymous request chose |
| 4207 |
* the moment the claim runs. Unlike its two neighbours in |
| 4208 |
* init_cleanup_hooks(), which only drop the plugin's own copy out of the |
| 4209 |
* database, the claim writes two network options, changes for the whole |
| 4210 |
* network the rule normalize_critical_file() applies, and re-bases the |
| 4211 |
* approved baseline. |
| 4212 |
* |
| 4213 |
* The gate lives here and not inside maybe_claim_owned_blocks() because the |
| 4214 |
* scan calls that one directly and the scan runs from wp-cron, with no user: |
| 4215 |
* putting the capability check inside left the claim unable to complete on |
| 4216 |
* any site whose dashboard nobody opens, and until it completes the older, |
| 4217 |
* permissive rule is the one in force, which is the hiding place 2.11.5 was |
| 4218 |
* written to close. Found by the cross review of 2.11.10. |
| 4219 |
* |
| 4220 |
* @since 2.11.10 |
| 4221 |
*/ |
| 4222 |
public function maybe_claim_owned_blocks_on_admin() { |
| 4223 |
if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) { |
| 4224 |
return; |
| 4225 |
} |
| 4226 |
|
| 4227 |
$this->maybe_claim_owned_blocks(); |
| 4228 |
} |
| 4229 |
|
| 4230 |
public function maybe_claim_owned_blocks() { |
| 4231 |
if ( $this->owned_blocks_claimed() || ! Vigilante_Settings::owns_shared_files() ) { |
| 4232 |
return; |
| 4233 |
} |
| 4234 |
|
| 4235 |
$sync_due = get_option( 'vigilante_server_files_pending' ) |
| 4236 |
|| VIGILANTE_VERSION !== get_option( 'vigilante_server_files_version' ); |
| 4237 |
|
| 4238 |
if ( $sync_due && ! get_option( 'vigilante_server_files_retry_after' ) ) { |
| 4239 |
return; |
| 4240 |
} |
| 4241 |
|
| 4242 |
$expected = null; |
| 4243 |
$unclaimed = array(); |
| 4244 |
|
| 4245 |
foreach ( $this->critical_root_files as $filename ) { |
| 4246 |
$full_path = $this->critical_file_path( $filename ); |
| 4247 |
|
| 4248 |
if ( false === $full_path ) { |
| 4249 |
continue; |
| 4250 |
} |
| 4251 |
|
| 4252 |
$content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 4253 |
|
| 4254 |
if ( false === $content ) { |
| 4255 |
// Unreadable right now: leave the claim open and try again later. |
| 4256 |
return; |
| 4257 |
} |
| 4258 |
|
| 4259 |
$content = str_replace( array( "\r\n", "\r" ), "\n", $content ); |
| 4260 |
$is_config = 'wp-config.php' === $filename; |
| 4261 |
|
| 4262 |
foreach ( ( $is_config ? $this->wpconfig_markers : $this->htaccess_markers ) as $markers ) { |
| 4263 |
$pattern = '/' . preg_quote( $markers[0], '/' ) . '.*?' . preg_quote( $markers[1], '/' ) . '/s'; |
| 4264 |
|
| 4265 |
if ( ! preg_match_all( $pattern, $content, $found ) ) { |
| 4266 |
continue; |
| 4267 |
} |
| 4268 |
|
| 4269 |
foreach ( $found[0] as $block ) { |
| 4270 |
if ( $is_config ) { |
| 4271 |
$ours = self::is_harmless_constants_block( $block, $markers ); |
| 4272 |
} else { |
| 4273 |
$expected = null === $expected ? $this->expected_htaccess_blocks() : $expected; |
| 4274 |
$ours = isset( $expected[ $markers[0] ] ) |
| 4275 |
&& self::comparable_block( $block ) === self::comparable_block( $expected[ $markers[0] ] ); |
| 4276 |
} |
| 4277 |
|
| 4278 |
if ( $ours ) { |
| 4279 |
self::remember_owned_block( $filename, $markers[0], $block, false ); |
| 4280 |
} else { |
| 4281 |
$unclaimed[ $filename ] = $filename; |
| 4282 |
} |
| 4283 |
} |
| 4284 |
} |
| 4285 |
|
| 4286 |
// The commented-out originals are judged line by line at scan time. |
| 4287 |
// Looking at them here only keeps the log entry below complete. |
| 4288 |
if ( $is_config && preg_match_all( '/^.*' . preg_quote( $this->wpconfig_original_marker, '/' ) . '.*$/m', $content, $marked ) ) { |
| 4289 |
foreach ( $marked[0] as $line ) { |
| 4290 |
if ( ! $this->is_vigilant_original_line( $line ) ) { |
| 4291 |
$unclaimed[ $filename ] = $filename; |
| 4292 |
} |
| 4293 |
} |
| 4294 |
} |
| 4295 |
} |
| 4296 |
|
| 4297 |
update_site_option( self::OWNED_BLOCKS_CLAIM_OPTION, self::OWNED_BLOCKS_CLAIMED ); |
| 4298 |
|
| 4299 |
// With the claim in place normalize uses the new rule, so a file nobody |
| 4300 |
// touched whose only difference is an original line the old rule dropped |
| 4301 |
// would read as changed. Re-base those, and only those, once. |
| 4302 |
$this->rebase_original_line_shift(); |
| 4303 |
|
| 4304 |
if ( $unclaimed && $this->activity_log ) { |
| 4305 |
$this->activity_log->log( |
| 4306 |
'file', |
| 4307 |
'critical_file_unrecognized_block', |
| 4308 |
sprintf( |
| 4309 |
/* translators: %s: comma-separated file names, such as wp-config.php or .htaccess. */ |
| 4310 |
__( 'Content marked as written by Vigilant in %s does not match what Vigilant writes. From now on it is checked like the rest of the file, so the file integrity scan reports it as a change for you to review.', 'vigilante' ), |
| 4311 |
implode( ', ', $unclaimed ) |
| 4312 |
), |
| 4313 |
array( 'files' => array_values( $unclaimed ) ), |
| 4314 |
'warning' |
| 4315 |
); |
| 4316 |
} |
| 4317 |
} |
| 4318 |
|
| 4319 |
/** |
| 4320 |
* Re-base the critical files whose only change is a newly kept original line |
| 4321 |
* |
| 4322 |
* Until 2.11.5 the hash left out every [VIGILANTE_ORIGINAL] line; from 2.11.5 |
| 4323 |
* it keeps the ones whose value is not a plain constant define, which is the |
| 4324 |
* right thing for the hash but moves it on a file nobody edited: the stored |
| 4325 |
* baseline was taken under the old rule, and nothing re-bases wp-config.php on |
| 4326 |
* an update (maybe_sync_server_files() only rewrites the .htaccess). So the |
| 4327 |
* first scan after updating would report wp-config.php as changed. |
| 4328 |
* |
| 4329 |
* This runs once, in the same pass that claims the blocks. For each file it |
| 4330 |
* re-bases to the new hash only when the baseline still matches the file with |
| 4331 |
* every original line dropped, which means the blocks are exactly the approved |
| 4332 |
* ones and the sole difference is those lines, the user's own commented-out |
| 4333 |
* defines. A block that was edited or planted does not match with the lines |
| 4334 |
* dropped, so it is left to be reported: this closes the false positive |
| 4335 |
* without adopting anything that was hidden before. |
| 4336 |
* |
| 4337 |
* @since 2.11.5 |
| 4338 |
*/ |
| 4339 |
private function rebase_original_line_shift() { |
| 4340 |
$baseline = $this->get_critical_files_baseline(); |
| 4341 |
$changed = false; |
| 4342 |
|
| 4343 |
foreach ( $this->critical_root_files as $filename ) { |
| 4344 |
$full_path = $this->critical_file_path( $filename ); |
| 4345 |
|
| 4346 |
if ( false === $full_path || empty( $baseline[ $filename ]['hash'] ) ) { |
| 4347 |
continue; |
| 4348 |
} |
| 4349 |
|
| 4350 |
$content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 4351 |
|
| 4352 |
if ( false === $content ) { |
| 4353 |
continue; |
| 4354 |
} |
| 4355 |
|
| 4356 |
/* |
| 4357 |
* Never re-base a file that carries a marked line which is not |
| 4358 |
* wholly a comment. The test below only establishes that the |
| 4359 |
* difference lies in lines carrying the marker, and the old rule |
| 4360 |
* dropped the WHOLE line, so a line with a statement in front of the |
| 4361 |
* marker satisfies it (cell X1 of matriz-escondite-marcadores.sh, |
| 4362 |
* where the shape is written out): re-basing would write that line |
| 4363 |
* into the approved baseline and rewrite the stored content, so the |
| 4364 |
* diff would stop showing it. Adopting as approved what the previous rule |
| 4365 |
* hid is the one thing an integrity scanner must never do, and the |
| 4366 |
* log entry of maybe_claim_owned_blocks() already promises the |
| 4367 |
* opposite ("the scan reports it as a change for you to review"). |
| 4368 |
* Those files are left to be reported. Found by the file-by-file |
| 4369 |
* review of 2.11.10. |
| 4370 |
* |
| 4371 |
* What counts as "wholly a comment" is decided by reading the file |
| 4372 |
* as PHP reads it, not by the shape of the line: see |
| 4373 |
* marked_lines_are_inert(). A line that is not recognised is not the |
| 4374 |
* same thing as a line that can run something, and the first |
| 4375 |
* wording of this guard confused the two. |
| 4376 |
*/ |
| 4377 |
if ( ! $this->marked_lines_are_inert( $content ) ) { |
| 4378 |
continue; |
| 4379 |
} |
| 4380 |
|
| 4381 |
$current = md5( $this->normalize_critical_file( $filename, $content ) ); |
| 4382 |
|
| 4383 |
// Already in step, or a real change to something other than the |
| 4384 |
// original lines: nothing to re-base here. |
| 4385 |
if ( $baseline[ $filename ]['hash'] === $current |
| 4386 |
|| $baseline[ $filename ]['hash'] !== md5( $this->normalize_critical_file( $filename, $content, true ) ) |
| 4387 |
) { |
| 4388 |
continue; |
| 4389 |
} |
| 4390 |
|
| 4391 |
$normalized = $this->normalize_critical_file( $filename, $content ); |
| 4392 |
$baseline[ $filename ]['hash'] = $current; |
| 4393 |
$baseline[ $filename ]['size'] = strlen( $content ); |
| 4394 |
$baseline[ $filename ]['content'] = $this->baseline_content( $filename, $normalized ); |
| 4395 |
$baseline[ $filename ]['updated'] = time(); |
| 4396 |
$changed = true; |
| 4397 |
} |
| 4398 |
|
| 4399 |
if ( $changed ) { |
| 4400 |
$this->write_baseline( $baseline ); |
| 4401 |
} |
| 4402 |
} |
| 4403 |
|
| 4404 |
/** |
| 4405 |
* The .htaccess blocks Vigilant would write today, keyed by start marker |
| 4406 |
* |
| 4407 |
* @since 2.11.5 |
| 4408 |
* |
| 4409 |
* @return array |
| 4410 |
*/ |
| 4411 |
private function expected_htaccess_blocks() { |
| 4412 |
$settings = $this->settings ? $this->settings : new Vigilante_Settings(); |
| 4413 |
|
| 4414 |
$classes = array( |
| 4415 |
'Vigilante_Htaccess_Protection' => 'class-htaccess-protection.php', |
| 4416 |
'Vigilante_Security_Headers' => 'class-security-headers.php', |
| 4417 |
); |
| 4418 |
|
| 4419 |
foreach ( $classes as $class => $file ) { |
| 4420 |
if ( ! class_exists( $class ) ) { |
| 4421 |
require_once VIGILANTE_INCLUDES_DIR . $file; |
| 4422 |
} |
| 4423 |
} |
| 4424 |
|
| 4425 |
$headers = new Vigilante_Security_Headers( $settings ); |
| 4426 |
|
| 4427 |
return array( |
| 4428 |
Vigilante_Htaccess_Protection::MARKER_START => ( new Vigilante_Htaccess_Protection( $settings ) )->generate_rules(), |
| 4429 |
Vigilante_Security_Headers::MARKER_START => Vigilante_Security_Headers::MARKER_START . "\n" . $headers->generate_rules_content() . "\n" . Vigilante_Security_Headers::MARKER_END, |
| 4430 |
); |
| 4431 |
} |
| 4432 |
|
| 4433 |
/** |
| 4434 |
* A .htaccess block with the parts that change on every write evened out |
| 4435 |
* |
| 4436 |
* Two blocks Vigilant wrote with the same settings differ only in the time |
| 4437 |
* they were generated and, across an update, in the version the firewall |
| 4438 |
* block names. Everything else has to be identical for the claim to take |
| 4439 |
* the block. |
| 4440 |
* |
| 4441 |
* @since 2.11.5 |
| 4442 |
* |
| 4443 |
* @param string $block Block from start marker to end marker, inclusive. |
| 4444 |
* @return string |
| 4445 |
*/ |
| 4446 |
private static function comparable_block( $block ) { |
| 4447 |
$block = str_replace( array( "\r\n", "\r" ), "\n", (string) $block ); |
| 4448 |
$block = preg_replace( '/^# Generated: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/m', '# Generated:', $block ); |
| 4449 |
$block = preg_replace( '/^# Vigilante for WordPress - Firewall v[0-9][0-9A-Za-z.\-]*$/m', '# Vigilante for WordPress - Firewall v', $block ); |
| 4450 |
|
| 4451 |
return rtrim( $block, "\n" ); |
| 4452 |
} |
| 4453 |
|
| 4454 |
/** |
| 4455 |
* Get stored baseline hashes for critical files |
| 4456 |
* |
| 4457 |
* @return array Associative array keyed by filename. |
| 4458 |
*/ |
| 4459 |
public function get_critical_files_baseline() { |
| 4460 |
return $this->read_baseline(); |
| 4461 |
} |
| 4462 |
|
| 4463 |
/** |
| 4464 |
* Update baseline hash for a single critical file |
| 4465 |
* |
| 4466 |
* Called by wp-config and htaccess writers after Vigilante modifies |
| 4467 |
* the file, so the next scan does not flag the change as suspicious. |
| 4468 |
* |
| 4469 |
* @param string $filename File name relative to ABSPATH (e.g. 'wp-config.php'). |
| 4470 |
* @return bool True on success. |
| 4471 |
*/ |
| 4472 |
public function update_critical_file_baseline( $filename ) { |
| 4473 |
$full_path = $this->critical_file_path( $filename ); |
| 4474 |
|
| 4475 |
if ( false === $full_path ) { |
| 4476 |
return false; |
| 4477 |
} |
| 4478 |
|
| 4479 |
$content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 4480 |
if ( false === $content ) { |
| 4481 |
return false; |
| 4482 |
} |
| 4483 |
|
| 4484 |
$normalized = $this->normalize_critical_file( $filename, $content ); |
| 4485 |
|
| 4486 |
$baseline = $this->get_critical_files_baseline(); |
| 4487 |
|
| 4488 |
/* |
| 4489 |
* No guard here, and there was one for a few hours during 2.11.4 that |
| 4490 |
* had to come out. It refused to rewrite the record when the stored hash |
| 4491 |
* no longer matched the file, meant to stop a write of ours from |
| 4492 |
* approving somebody else's pending edit. Two things were wrong with it, |
| 4493 |
* both measured on 10 sep 2026 by a third cross review: |
| 4494 |
* |
| 4495 |
* - This is also the Approve button (Vigilante_Admin_Ajax:: |
| 4496 |
* ajax_approve_critical_file). A moved hash is exactly the state in |
| 4497 |
* which Approve is pressed, so the guard made Approve fail every time |
| 4498 |
* and the warning could never be closed. |
| 4499 |
* - Its premise, "our own write cannot move the normalized hash", holds |
| 4500 |
* for the block and not for the rest of what the writers do. |
| 4501 |
* comment_existing_constants() turns a define() into a |
| 4502 |
* [VIGILANTE_ORIGINAL] line that normalize_critical_file() leaves as |
| 4503 |
* an empty line, and remove_old_rules() deletes legacy .htaccess blocks |
| 4504 |
* that normalize_critical_file() does not know. Both move the hash, so |
| 4505 |
* the guard would have raised a false "file modified" after Vigilant's |
| 4506 |
* own work, on sites that updated. |
| 4507 |
* |
| 4508 |
* The real fix is to know what the hash was before WE touched the file: |
| 4509 |
* the writers capture it and pass it along vigilante_critical_file_ |
| 4510 |
* written, and this compares against that instead of against the |
| 4511 |
* record. Until then this behaves as it always has, which does mean a |
| 4512 |
* write of ours can adopt a third-party edit that was pending review. |
| 4513 |
* That is pre-existing, and written down in the roadmap. |
| 4514 |
*/ |
| 4515 |
$baseline[ $filename ] = array( |
| 4516 |
'hash' => md5( $normalized ), |
| 4517 |
'size' => strlen( $content ), |
| 4518 |
'content' => $this->baseline_content( $filename, $normalized ), |
| 4519 |
'updated' => time(), |
| 4520 |
); |
| 4521 |
|
| 4522 |
return $this->write_baseline( $baseline ); |
| 4523 |
} |
| 4524 |
|
| 4525 |
/** |
| 4526 |
* Regenerate baseline for all critical files |
| 4527 |
* |
| 4528 |
* Used by the admin UI button and the 1.14.0 migration. |
| 4529 |
* |
| 4530 |
* @return array Updated baseline data. |
| 4531 |
*/ |
| 4532 |
public function regenerate_all_baselines() { |
| 4533 |
$baseline = array(); |
| 4534 |
|
| 4535 |
foreach ( $this->critical_root_files as $filename ) { |
| 4536 |
$full_path = $this->critical_file_path( $filename ); |
| 4537 |
|
| 4538 |
if ( false === $full_path ) { |
| 4539 |
continue; |
| 4540 |
} |
| 4541 |
|
| 4542 |
$content = file_get_contents( $full_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 4543 |
if ( false === $content ) { |
| 4544 |
continue; |
| 4545 |
} |
| 4546 |
|
| 4547 |
$normalized = $this->normalize_critical_file( $filename, $content ); |
| 4548 |
$baseline[ $filename ] = array( |
| 4549 |
'hash' => md5( $normalized ), |
| 4550 |
'size' => strlen( $content ), |
| 4551 |
'content' => $this->baseline_content( $filename, $normalized ), |
| 4552 |
'updated' => time(), |
| 4553 |
); |
| 4554 |
} |
| 4555 |
|
| 4556 |
$this->write_baseline( $baseline ); |
| 4557 |
|
| 4558 |
return $baseline; |
| 4559 |
} |
| 4560 |
|
| 4561 |
/** |
| 4562 |
* Get core checksums from WordPress.org API |
| 4563 |
* |
| 4564 |
* @param bool $force_refresh Skip the cached copy and ask wp.org again. |
| 4565 |
* @return array|WP_Error Checksums or error. |
| 4566 |
*/ |
| 4567 |
private function get_core_checksums( $force_refresh = false, $locale = null, $english_fallback = true ) { |
| 4568 |
$locale = null === $locale ? get_locale() : $locale; |
| 4569 |
$version = $this->wp_version; |
| 4570 |
|
| 4571 |
// Check cache first |
| 4572 |
$cache_key = 'vigilante_core_checksums_' . md5( $version . $locale ); |
| 4573 |
|
| 4574 |
if ( $force_refresh ) { |
| 4575 |
delete_transient( $cache_key ); |
| 4576 |
} |
| 4577 |
|
| 4578 |
$cached = get_transient( $cache_key ); |
| 4579 |
if ( false !== $cached ) { |
| 4580 |
return $cached; |
| 4581 |
} |
| 4582 |
|
| 4583 |
// Fetch from WordPress.org |
| 4584 |
$url = sprintf( |
| 4585 |
'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s', |
| 4586 |
$version, |
| 4587 |
$locale |
| 4588 |
); |
| 4589 |
|
| 4590 |
$response = wp_remote_get( $url, array( 'timeout' => 10 ) ); |
| 4591 |
|
| 4592 |
if ( is_wp_error( $response ) ) { |
| 4593 |
return $response; |
| 4594 |
} |
| 4595 |
|
| 4596 |
$body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 4597 |
|
| 4598 |
if ( empty( $body['checksums'] ) ) { |
| 4599 |
/* |
| 4600 |
* WordPress.org has no list for this version in this language. That |
| 4601 |
* is the normal state of every site that is not in English for the |
| 4602 |
* hours or days after a release, until the package of its language |
| 4603 |
* is built, and of the languages that never get one. Until now the |
| 4604 |
* core was then not compared at all. Those sites run the English |
| 4605 |
* files, so the English list is the one to compare with. It is not |
| 4606 |
* kept under this language, so the next scan asks again. |
| 4607 |
*/ |
| 4608 |
if ( 'en_US' !== $locale && $english_fallback ) { |
| 4609 |
$english = $this->get_core_checksums( $force_refresh, 'en_US' ); |
| 4610 |
|
| 4611 |
if ( is_array( $english ) ) { |
| 4612 |
$this->core_checksums_are_english = true; |
| 4613 |
return $english; |
| 4614 |
} |
| 4615 |
} |
| 4616 |
|
| 4617 |
return new WP_Error( 'no_checksums', __( 'Could not retrieve WordPress core checksums', 'vigilante' ) ); |
| 4618 |
} |
| 4619 |
|
| 4620 |
$checksums = $body['checksums']; |
| 4621 |
|
| 4622 |
// Handle nested format: checksums keyed under version string (WP 6.9+) |
| 4623 |
if ( isset( $checksums[ $version ] ) && is_array( $checksums[ $version ] ) ) { |
| 4624 |
$checksums = $checksums[ $version ]; |
| 4625 |
} |
| 4626 |
|
| 4627 |
// Cache for 24 hours |
| 4628 |
set_transient( $cache_key, $checksums, DAY_IN_SECONDS ); |
| 4629 |
|
| 4630 |
return $checksums; |
| 4631 |
} |
| 4632 |
|
| 4633 |
/** |
| 4634 |
* Whether a core file is the official copy of another language |
| 4635 |
* |
| 4636 |
* The checksums are asked for in the language of the site, and a site does |
| 4637 |
* not always run the package of its language: one installed in English and |
| 4638 |
* switched to Spanish afterwards keeps the English files, so its |
| 4639 |
* version.php and its wp-config-sample.php do not match the Spanish list. |
| 4640 |
* That is why version.php used to be left out of the comparison altogether, |
| 4641 |
* which left a file WordPress loads on every request unchecked. A file that |
| 4642 |
* matches the official copy of the same version in English, or in the |
| 4643 |
* language its own package declares, is an official file. The language |
| 4644 |
* only chooses which official list to look at: a file that was tampered |
| 4645 |
* with matches none of them. |
| 4646 |
* |
| 4647 |
* @param string $file Path relative to the WordPress root. |
| 4648 |
* @param string $actual_hash md5 of the file on disk. |
| 4649 |
* @return bool |
| 4650 |
*/ |
| 4651 |
private function matches_core_of_other_locale( $file, $actual_hash ) { |
| 4652 |
$current = get_locale(); |
| 4653 |
$others = array( 'en_US' ); |
| 4654 |
|
| 4655 |
if ( ! empty( $GLOBALS['wp_local_package'] ) && is_string( $GLOBALS['wp_local_package'] ) ) { |
| 4656 |
$others[] = $GLOBALS['wp_local_package']; |
| 4657 |
} |
| 4658 |
|
| 4659 |
foreach ( array_unique( $others ) as $locale ) { |
| 4660 |
if ( $locale === $current || ! preg_match( '/^[A-Za-z0-9_]{2,24}$/', $locale ) ) { |
| 4661 |
continue; |
| 4662 |
} |
| 4663 |
|
| 4664 |
// Without the English list in its place: asking about another |
| 4665 |
// language must not leave the scan believing that the list of the |
| 4666 |
// site itself was missing. |
| 4667 |
$checksums = $this->get_core_checksums( false, $locale, false ); |
| 4668 |
|
| 4669 |
if ( is_array( $checksums ) && isset( $checksums[ $file ] ) && $checksums[ $file ] === $actual_hash ) { |
| 4670 |
return true; |
| 4671 |
} |
| 4672 |
} |
| 4673 |
|
| 4674 |
return false; |
| 4675 |
} |
| 4676 |
|
| 4677 |
/** |
| 4678 |
* Scan plugins for modifications |
| 4679 |
* |
| 4680 |
* @return array Scan results. |
| 4681 |
*/ |
| 4682 |
private function scan_plugins() { |
| 4683 |
$results = array( |
| 4684 |
'scanned' => 0, |
| 4685 |
'ok' => 0, |
| 4686 |
'modified' => array(), |
| 4687 |
'suspicious' => array(), |
| 4688 |
'extra' => array(), |
| 4689 |
'errors' => array(), |
| 4690 |
); |
| 4691 |
|
| 4692 |
// Get all installed plugins |
| 4693 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 4694 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 4695 |
} |
| 4696 |
|
| 4697 |
$plugins = get_plugins(); |
| 4698 |
$finished = array(); |
| 4699 |
|
| 4700 |
foreach ( $plugins as $plugin_file => $plugin_data ) { |
| 4701 |
// Check time limit |
| 4702 |
if ( $this->is_time_exceeded() ) { |
| 4703 |
break; |
| 4704 |
} |
| 4705 |
|
| 4706 |
$plugin_slug = dirname( $plugin_file ); |
| 4707 |
|
| 4708 |
if ( ! $this->plugin_has_turn( $plugin_slug ) ) { |
| 4709 |
continue; |
| 4710 |
} |
| 4711 |
|
| 4712 |
// Get checksums from WordPress.org |
| 4713 |
$version = $plugin_data['Version'] ?? ''; |
| 4714 |
$checksums = $this->get_plugin_checksums( $plugin_slug, $version ); |
| 4715 |
|
| 4716 |
$has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums; |
| 4717 |
|
| 4718 |
// Say which plugins could not be compared, and why. A plugin that |
| 4719 |
// is not on WordPress.org is named as such; a failed request is an |
| 4720 |
// error of this scan, not a plugin without an official copy. |
| 4721 |
if ( is_wp_error( $checksums ) ) { |
| 4722 |
$results['errors'][] = sprintf( |
| 4723 |
/* translators: 1: Plugin name, 2: What went wrong */ |
| 4724 |
__( 'The plugin %1$s could not be compared with its copy on WordPress.org: %2$s', 'vigilante' ), |
| 4725 |
$plugin_data['Name'] ?? $plugin_slug, |
| 4726 |
$checksums->get_error_message() |
| 4727 |
); |
| 4728 |
} elseif ( ! $has_checksums ) { |
| 4729 |
$results['unverified'][] = $plugin_data['Name'] ?? $plugin_slug; |
| 4730 |
} |
| 4731 |
|
| 4732 |
$plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug; |
| 4733 |
|
| 4734 |
// Check known files against checksums (only if available) |
| 4735 |
if ( $has_checksums ) { |
| 4736 |
foreach ( $checksums as $file => $expected_hash ) { |
| 4737 |
// Check time limit inside inner loop too |
| 4738 |
if ( $this->is_time_exceeded() ) { |
| 4739 |
break 2; // Break both loops |
| 4740 |
} |
| 4741 |
|
| 4742 |
$file_path = $plugin_dir . '/' . $file; |
| 4743 |
|
| 4744 |
// Skip excluded paths |
| 4745 |
if ( $this->is_path_excluded( $file_path ) ) { |
| 4746 |
continue; |
| 4747 |
} |
| 4748 |
|
| 4749 |
// Skip excluded extensions |
| 4750 |
if ( $this->is_extension_excluded( $file_path ) ) { |
| 4751 |
continue; |
| 4752 |
} |
| 4753 |
|
| 4754 |
// Skip known false positives (e.g. readme.txt, readme.md) |
| 4755 |
if ( in_array( $file, $this->plugin_known_false_positives, true ) ) { |
| 4756 |
continue; |
| 4757 |
} |
| 4758 |
|
| 4759 |
$results['scanned']++; |
| 4760 |
|
| 4761 |
if ( ! file_exists( $file_path ) ) { |
| 4762 |
// Reported, and not just skipped: a file that is in the |
| 4763 |
// published copy and not on the server is part of what |
| 4764 |
// the scan found. |
| 4765 |
$results['missing'][] = array( |
| 4766 |
'file' => 'plugins/' . $plugin_slug . '/' . $file, |
| 4767 |
'type' => 'plugin', |
| 4768 |
'plugin' => $plugin_data['Name'] ?? $plugin_slug, |
| 4769 |
); |
| 4770 |
continue; |
| 4771 |
} |
| 4772 |
|
| 4773 |
if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) { |
| 4774 |
$results['modified'][] = array( |
| 4775 |
'file' => 'plugins/' . $plugin_slug . '/' . $file, |
| 4776 |
'type' => 'plugin', |
| 4777 |
'plugin' => $plugin_data['Name'], |
| 4778 |
'expected_hash' => $this->expected_hash_label( $expected_hash ), |
| 4779 |
'actual_hash' => md5_file( $file_path ), |
| 4780 |
); |
| 4781 |
} else { |
| 4782 |
$results['ok']++; |
| 4783 |
} |
| 4784 |
} |
| 4785 |
} // end if $has_checksums |
| 4786 |
|
| 4787 |
// Detect extra/suspicious files |
| 4788 |
// With checksums: finds files not in the original distribution |
| 4789 |
// Without checksums: scans ALL plugin files but only flags suspicious patterns |
| 4790 |
if ( ! $this->is_time_exceeded() ) { |
| 4791 |
$known_files = $has_checksums ? $checksums : array(); |
| 4792 |
$suspicious_only = ! $has_checksums; // Without checksums, only report files with suspicious code |
| 4793 |
$extra_results = $this->detect_extra_files( $plugin_dir, $known_files, 'plugin', $plugin_data['Name'], $suspicious_only ); |
| 4794 |
$results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] ); |
| 4795 |
$results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] ); |
| 4796 |
$results['errors'] = array_merge( $results['errors'] ?? array(), $extra_results['errors'] ?? array() ); |
| 4797 |
} |
| 4798 |
|
| 4799 |
// The plugin had its whole turn. The one the time runs out on |
| 4800 |
// does not get here with time left, and is named with the ones |
| 4801 |
// behind it. |
| 4802 |
if ( ! $this->is_time_exceeded() ) { |
| 4803 |
$finished[ $plugin_file ] = true; |
| 4804 |
|
| 4805 |
// Its folder, as a line for Excluded Paths: what a partial |
| 4806 |
// scan offers so that the next one spends its time on the |
| 4807 |
// rest. One that is already excluded was not looked at. |
| 4808 |
if ( ! $this->is_path_excluded( $plugin_dir . '/.' ) ) { |
| 4809 |
$results['checked'][] = $this->relative_path( $plugin_dir ); |
| 4810 |
} |
| 4811 |
} |
| 4812 |
} |
| 4813 |
|
| 4814 |
// The plugins the time did not reach, by name. |
| 4815 |
$results['not_checked'] = array(); |
| 4816 |
|
| 4817 |
foreach ( $plugins as $plugin_file => $plugin_data ) { |
| 4818 |
if ( isset( $finished[ $plugin_file ] ) || ! $this->plugin_has_turn( dirname( $plugin_file ) ) ) { |
| 4819 |
continue; |
| 4820 |
} |
| 4821 |
|
| 4822 |
$results['not_checked'][] = (string) ( $plugin_data['Name'] ?? dirname( $plugin_file ) ); |
| 4823 |
} |
| 4824 |
|
| 4825 |
// Must-use plugins, drop-ins and loose PHP files, which have no |
| 4826 |
// official copy and no entry in the list above. |
| 4827 |
$results['unlisted_checked'] = false; |
| 4828 |
|
| 4829 |
if ( ! $this->is_time_exceeded() ) { |
| 4830 |
$unlisted = $this->scan_unlisted_code(); |
| 4831 |
|
| 4832 |
$results['extra'] = array_merge( $results['extra'] ?? array(), $unlisted['extra'] ); |
| 4833 |
$results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $unlisted['suspicious'] ); |
| 4834 |
$results['unverified'] = array_merge( $results['unverified'] ?? array(), $unlisted['unverified'] ); |
| 4835 |
$results['errors'] = array_merge( $results['errors'] ?? array(), $unlisted['errors'] ); |
| 4836 |
|
| 4837 |
$results['unlisted_checked'] = ! $this->is_time_exceeded(); |
| 4838 |
} |
| 4839 |
|
| 4840 |
return $results; |
| 4841 |
} |
| 4842 |
|
| 4843 |
/** |
| 4844 |
* Whether a plugin gets a turn of its own in scan_plugins() |
| 4845 |
* |
| 4846 |
* @param string $plugin_slug Folder of the plugin, or "." for a single file. |
| 4847 |
* @return bool |
| 4848 |
*/ |
| 4849 |
private function plugin_has_turn( $plugin_slug ) { |
| 4850 |
// Single-file plugins are loose PHP files: scan_unlisted_code(). |
| 4851 |
if ( '.' === $plugin_slug ) { |
| 4852 |
return false; |
| 4853 |
} |
| 4854 |
|
| 4855 |
// With the self-check on, Vigilant itself is verified by the |
| 4856 |
// sha256 triple-anchor block at the start of run_scan(): scanning |
| 4857 |
// it here again would duplicate findings and the md5 fetch. With |
| 4858 |
// the check off by filter, Vigilant is a regular plugin (legacy |
| 4859 |
// behaviour). |
| 4860 |
if ( dirname( VIGILANTE_PLUGIN_BASENAME ) === $plugin_slug && Vigilante_Self_Integrity::is_on() ) { |
| 4861 |
return false; |
| 4862 |
} |
| 4863 |
|
| 4864 |
// Skip slugs in their post-update grace window: wp.org may still be |
| 4865 |
// publishing the new version's checksums, so a scheduled scan here |
| 4866 |
// would raise benign "modified/extra" noise. The dedicated post-update |
| 4867 |
// verifier (vigilante_fi_postupdate_verify) handles these instead. |
| 4868 |
return ! $this->in_post_update_grace( 'plugin', $plugin_slug ); |
| 4869 |
} |
| 4870 |
|
| 4871 |
/** |
| 4872 |
* Whether a file on disk matches a hash WordPress.org publishes for it. |
| 4873 |
* |
| 4874 |
* The wp.org checksums JSON gives, per file, an md5 (and usually a sha256) |
| 4875 |
* that may be a single string OR an array of strings: a file whose content |
| 4876 |
* differs across the re-tagged zips that one checksums file covers (e.g. a |
| 4877 |
* release that shares its checksums file with a beta) gets every valid hash |
| 4878 |
* listed as an array. The old `$actual !== $expected` comparison evaluated a |
| 4879 |
* 32-char string against an array as unequal unconditionally, so those files |
| 4880 |
* were always reported as "modified" even when the on-disk hash was one of |
| 4881 |
* the published ones. Match against membership, and accept either md5 or |
| 4882 |
* sha256, so the comparison is correct and strictly stronger than md5-only. |
| 4883 |
* |
| 4884 |
* Robust to both shapes: the new record array( 'md5' => ..., 'sha256' => ... ) |
| 4885 |
* and a legacy cached value (a bare md5 string or array), so a transient |
| 4886 |
* cached by an older version still compares correctly until it expires. |
| 4887 |
* |
| 4888 |
* @param string $file_path Absolute path to the file on disk. |
| 4889 |
* @param array|string $expected Record array, or a legacy md5 string|array. |
| 4890 |
* @return bool True when the file matches a published hash. |
| 4891 |
*/ |
| 4892 |
private function hash_matches_published( $file_path, $expected ) { |
| 4893 |
$md5 = $expected; |
| 4894 |
$sha = null; |
| 4895 |
if ( is_array( $expected ) && ( array_key_exists( 'md5', $expected ) || array_key_exists( 'sha256', $expected ) ) ) { |
| 4896 |
$md5 = isset( $expected['md5'] ) ? $expected['md5'] : null; |
| 4897 |
$sha = isset( $expected['sha256'] ) ? $expected['sha256'] : null; |
| 4898 |
} |
| 4899 |
|
| 4900 |
if ( null !== $md5 && in_array( md5_file( $file_path ), (array) $md5, true ) ) { |
| 4901 |
return true; |
| 4902 |
} |
| 4903 |
if ( ! empty( $sha ) && in_array( hash_file( 'sha256', $file_path ), (array) $sha, true ) ) { |
| 4904 |
return true; |
| 4905 |
} |
| 4906 |
|
| 4907 |
// Fallback: some hosts and deploy pipelines rewrite text files on disk |
| 4908 |
// (prepend a UTF-8 BOM, or convert LF line endings to CRLF) without |
| 4909 |
// changing a single line of code. That alters the raw bytes, so the |
| 4910 |
// md5/sha256 stops matching WordPress.org even though the file is |
| 4911 |
// intact, which surfaced as false "modified file" alerts. Retry the |
| 4912 |
// comparison against a normalized copy (BOM stripped, CRLF/CR collapsed |
| 4913 |
// to LF) for text files only, so a genuine code change is still caught. |
| 4914 |
if ( is_string( $file_path ) && '' !== $file_path && $this->is_text_file( $file_path ) && is_readable( $file_path ) ) { |
| 4915 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read for hashing, not remote. |
| 4916 |
$content = file_get_contents( $file_path ); |
| 4917 |
if ( false !== $content ) { |
| 4918 |
if ( "\xEF\xBB\xBF" === substr( $content, 0, 3 ) ) { |
| 4919 |
$content = substr( $content, 3 ); |
| 4920 |
} |
| 4921 |
$content = str_replace( array( "\r\n", "\r" ), "\n", $content ); |
| 4922 |
if ( null !== $md5 && in_array( md5( $content ), (array) $md5, true ) ) { |
| 4923 |
return true; |
| 4924 |
} |
| 4925 |
if ( ! empty( $sha ) && in_array( hash( 'sha256', $content ), (array) $sha, true ) ) { |
| 4926 |
return true; |
| 4927 |
} |
| 4928 |
} |
| 4929 |
} |
| 4930 |
|
| 4931 |
return false; |
| 4932 |
} |
| 4933 |
|
| 4934 |
/** |
| 4935 |
* Human-readable expected-hash value for a modified-file result record. |
| 4936 |
* |
| 4937 |
* The published md5 may be a string or an array; flatten it for storage. |
| 4938 |
* |
| 4939 |
* @param array|string $expected Record array or legacy md5 string|array. |
| 4940 |
* @return string |
| 4941 |
*/ |
| 4942 |
private function expected_hash_label( $expected ) { |
| 4943 |
$md5 = ( is_array( $expected ) && array_key_exists( 'md5', $expected ) ) ? $expected['md5'] : $expected; |
| 4944 |
if ( is_array( $md5 ) ) { |
| 4945 |
return implode( ', ', array_map( 'strval', $md5 ) ); |
| 4946 |
} |
| 4947 |
return (string) $md5; |
| 4948 |
} |
| 4949 |
|
| 4950 |
/** |
| 4951 |
* Whether a plugin/theme slug is inside its post-update grace window. |
| 4952 |
* |
| 4953 |
* Set by on_upgrade_complete() right after WordPress finishes updating a |
| 4954 |
* plugin or theme. During the window the checksum cache is bypassed (so a |
| 4955 |
* stale manifest cached during wp.org's propagation lag is never reused) and |
| 4956 |
* the scheduled scan skips the slug (the dedicated post-update verifier |
| 4957 |
* handles it instead), which is what stops the "files don't match |
| 4958 |
* WordPress.org" false positives right after an update. |
| 4959 |
* |
| 4960 |
* @param string $type 'plugin' or 'theme'. |
| 4961 |
* @param string $slug Slug. |
| 4962 |
* @return bool |
| 4963 |
*/ |
| 4964 |
private function in_post_update_grace( $type, $slug ) { |
| 4965 |
return (bool) get_transient( 'vigilante_fi_grace_' . $type . '_' . md5( $slug ) ); |
| 4966 |
} |
| 4967 |
|
| 4968 |
/** |
| 4969 |
* Get plugin checksums from WordPress.org |
| 4970 |
* |
| 4971 |
* @param string $slug Plugin slug. |
| 4972 |
* @param string $version Plugin version. |
| 4973 |
* @return array|WP_Error|string |
| 4974 |
*/ |
| 4975 |
private function get_plugin_checksums( $slug, $version ) { |
| 4976 |
$cache_key = 'vigilante_plugin_checksums_' . md5( $slug . $version ); |
| 4977 |
|
| 4978 |
// During the post-update grace window, bypass the cache entirely so a |
| 4979 |
// manifest cached while wp.org was still propagating the new version's |
| 4980 |
// checksums can never be reused. Fetch fresh and do not write it back. |
| 4981 |
$grace = $this->in_post_update_grace( 'plugin', $slug ); |
| 4982 |
|
| 4983 |
if ( ! $grace ) { |
| 4984 |
$cached = get_transient( $cache_key ); |
| 4985 |
if ( false !== $cached ) { |
| 4986 |
return $cached; |
| 4987 |
} |
| 4988 |
} |
| 4989 |
|
| 4990 |
$url = sprintf( |
| 4991 |
'https://downloads.wordpress.org/plugin-checksums/%s/%s.json', |
| 4992 |
$slug, |
| 4993 |
$version |
| 4994 |
); |
| 4995 |
|
| 4996 |
$response = wp_remote_get( $url, array( 'timeout' => 10 ) ); |
| 4997 |
|
| 4998 |
if ( is_wp_error( $response ) ) { |
| 4999 |
return $response; |
| 5000 |
} |
| 5001 |
|
| 5002 |
$status = wp_remote_retrieve_response_code( $response ); |
| 5003 |
if ( 404 === (int) $status ) { |
| 5004 |
// Cache "not found" to avoid repeated requests (never during grace). |
| 5005 |
if ( ! $grace ) { |
| 5006 |
set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS ); |
| 5007 |
} |
| 5008 |
return 'not_found'; |
| 5009 |
} |
| 5010 |
|
| 5011 |
if ( 200 !== (int) $status ) { |
| 5012 |
// WordPress.org answered, but not with the checksums. An outage is |
| 5013 |
// not a plugin with no published copy, and until now it passed |
| 5014 |
// for one: the plugin was quietly left uncompared. |
| 5015 |
return new WP_Error( |
| 5016 |
'http_error', |
| 5017 |
/* translators: %d: HTTP status code */ |
| 5018 |
sprintf( __( 'WordPress.org answered with status %d', 'vigilante' ), (int) $status ) |
| 5019 |
); |
| 5020 |
} |
| 5021 |
|
| 5022 |
$body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 5023 |
|
| 5024 |
if ( empty( $body['files'] ) ) { |
| 5025 |
return new WP_Error( 'no_checksums', __( 'No checksums found', 'vigilante' ) ); |
| 5026 |
} |
| 5027 |
|
| 5028 |
// Store the full per-file record (md5 + sha256). Either value can be a |
| 5029 |
// string or an array of strings; hash_matches_published() handles both. |
| 5030 |
$checksums = array(); |
| 5031 |
foreach ( $body['files'] as $file => $data ) { |
| 5032 |
$checksums[ $file ] = array( |
| 5033 |
'md5' => isset( $data['md5'] ) ? $data['md5'] : null, |
| 5034 |
'sha256' => isset( $data['sha256'] ) ? $data['sha256'] : null, |
| 5035 |
); |
| 5036 |
} |
| 5037 |
|
| 5038 |
// Cache for 24 hours (never during grace, to avoid persisting a manifest |
| 5039 |
// wp.org may still be regenerating). |
| 5040 |
if ( ! $grace ) { |
| 5041 |
set_transient( $cache_key, $checksums, DAY_IN_SECONDS ); |
| 5042 |
} |
| 5043 |
|
| 5044 |
return $checksums; |
| 5045 |
} |
| 5046 |
|
| 5047 |
/** |
| 5048 |
* Scan themes for modifications |
| 5049 |
* |
| 5050 |
* @return array Scan results. |
| 5051 |
*/ |
| 5052 |
private function scan_themes() { |
| 5053 |
$results = array( |
| 5054 |
'scanned' => 0, |
| 5055 |
'ok' => 0, |
| 5056 |
'modified' => array(), |
| 5057 |
'suspicious' => array(), |
| 5058 |
'extra' => array(), |
| 5059 |
'errors' => array(), |
| 5060 |
); |
| 5061 |
|
| 5062 |
$themes = wp_get_themes(); |
| 5063 |
$finished = array(); |
| 5064 |
|
| 5065 |
foreach ( $themes as $theme_slug => $theme ) { |
| 5066 |
// Check time limit |
| 5067 |
if ( $this->is_time_exceeded() ) { |
| 5068 |
break; |
| 5069 |
} |
| 5070 |
|
| 5071 |
// Skip slugs in their post-update grace window (see scan_plugins()). |
| 5072 |
if ( $this->in_post_update_grace( 'theme', $theme_slug ) ) { |
| 5073 |
continue; |
| 5074 |
} |
| 5075 |
|
| 5076 |
$version = $theme->get( 'Version' ); |
| 5077 |
$checksums = $this->get_theme_checksums( $theme_slug, $version ); |
| 5078 |
|
| 5079 |
$has_checksums = ! is_wp_error( $checksums ) && 'not_found' !== $checksums; |
| 5080 |
|
| 5081 |
// Same as for plugins: name what could not be compared, and why. |
| 5082 |
if ( is_wp_error( $checksums ) && 'not_found' !== $checksums->get_error_code() ) { |
| 5083 |
$results['errors'][] = sprintf( |
| 5084 |
/* translators: 1: Theme name, 2: What went wrong */ |
| 5085 |
__( 'The theme %1$s could not be compared with its copy on WordPress.org: %2$s', 'vigilante' ), |
| 5086 |
$theme->get( 'Name' ), |
| 5087 |
$checksums->get_error_message() |
| 5088 |
); |
| 5089 |
} elseif ( ! $has_checksums ) { |
| 5090 |
$results['unverified'][] = $theme->get( 'Name' ); |
| 5091 |
} |
| 5092 |
|
| 5093 |
$theme_dir = $theme->get_stylesheet_directory(); |
| 5094 |
|
| 5095 |
// Check known files against checksums (only if available) |
| 5096 |
if ( $has_checksums ) { |
| 5097 |
foreach ( $checksums as $file => $expected_hash ) { |
| 5098 |
// Check time limit inside inner loop too |
| 5099 |
if ( $this->is_time_exceeded() ) { |
| 5100 |
break 2; // Break both loops |
| 5101 |
} |
| 5102 |
|
| 5103 |
$file_path = $theme_dir . '/' . $file; |
| 5104 |
|
| 5105 |
// Skip excluded paths |
| 5106 |
if ( $this->is_path_excluded( $file_path ) ) { |
| 5107 |
continue; |
| 5108 |
} |
| 5109 |
|
| 5110 |
// Skip excluded extensions |
| 5111 |
if ( $this->is_extension_excluded( $file_path ) ) { |
| 5112 |
continue; |
| 5113 |
} |
| 5114 |
|
| 5115 |
// Skip known false positives (e.g. readme.txt, readme.md) |
| 5116 |
if ( in_array( $file, $this->plugin_known_false_positives, true ) ) { |
| 5117 |
continue; |
| 5118 |
} |
| 5119 |
|
| 5120 |
$results['scanned']++; |
| 5121 |
|
| 5122 |
if ( ! file_exists( $file_path ) ) { |
| 5123 |
$results['missing'][] = array( |
| 5124 |
'file' => 'themes/' . $theme_slug . '/' . $file, |
| 5125 |
'type' => 'theme', |
| 5126 |
'theme' => $theme->get( 'Name' ), |
| 5127 |
); |
| 5128 |
continue; |
| 5129 |
} |
| 5130 |
|
| 5131 |
if ( ! $this->hash_matches_published( $file_path, $expected_hash ) ) { |
| 5132 |
$results['modified'][] = array( |
| 5133 |
'file' => 'themes/' . $theme_slug . '/' . $file, |
| 5134 |
'type' => 'theme', |
| 5135 |
'theme' => $theme->get( 'Name' ), |
| 5136 |
'expected_hash' => $this->expected_hash_label( $expected_hash ), |
| 5137 |
'actual_hash' => md5_file( $file_path ), |
| 5138 |
); |
| 5139 |
} else { |
| 5140 |
$results['ok']++; |
| 5141 |
} |
| 5142 |
} |
| 5143 |
} // end if $has_checksums |
| 5144 |
|
| 5145 |
// Detect extra/suspicious files |
| 5146 |
if ( ! $this->is_time_exceeded() ) { |
| 5147 |
$known_files = $has_checksums ? $checksums : array(); |
| 5148 |
$suspicious_only = ! $has_checksums; |
| 5149 |
$extra_results = $this->detect_extra_files( $theme_dir, $known_files, 'theme', $theme->get( 'Name' ), $suspicious_only ); |
| 5150 |
$results['extra'] = array_merge( $results['extra'] ?? array(), $extra_results['extra'] ); |
| 5151 |
$results['suspicious'] = array_merge( $results['suspicious'] ?? array(), $extra_results['suspicious'] ); |
| 5152 |
$results['errors'] = array_merge( $results['errors'] ?? array(), $extra_results['errors'] ?? array() ); |
| 5153 |
} |
| 5154 |
|
| 5155 |
// Same as for plugins: a whole turn, with time left. |
| 5156 |
if ( ! $this->is_time_exceeded() ) { |
| 5157 |
$finished[ $theme_slug ] = true; |
| 5158 |
|
| 5159 |
if ( ! $this->is_path_excluded( $theme_dir . '/.' ) ) { |
| 5160 |
$results['checked'][] = $this->relative_path( $theme_dir ); |
| 5161 |
} |
| 5162 |
} |
| 5163 |
} |
| 5164 |
|
| 5165 |
// The themes the time did not reach, by name. |
| 5166 |
$results['not_checked'] = array(); |
| 5167 |
|
| 5168 |
foreach ( $themes as $theme_slug => $theme ) { |
| 5169 |
if ( isset( $finished[ $theme_slug ] ) || $this->in_post_update_grace( 'theme', $theme_slug ) ) { |
| 5170 |
continue; |
| 5171 |
} |
| 5172 |
|
| 5173 |
$results['not_checked'][] = (string) $theme->get( 'Name' ); |
| 5174 |
} |
| 5175 |
|
| 5176 |
return $results; |
| 5177 |
} |
| 5178 |
|
| 5179 |
/** |
| 5180 |
* Get theme checksums from WordPress.org |
| 5181 |
* |
| 5182 |
* @param string $slug Theme slug. |
| 5183 |
* @param string $version Theme version. |
| 5184 |
* @return array|WP_Error |
| 5185 |
*/ |
| 5186 |
private function get_theme_checksums( $slug, $version ) { |
| 5187 |
$cache_key = 'vigilante_theme_package_' . md5( $slug . $version ); |
| 5188 |
|
| 5189 |
// Bypass the cache during the post-update grace window (see plugin path). |
| 5190 |
$grace = $this->in_post_update_grace( 'theme', $slug ); |
| 5191 |
|
| 5192 |
if ( ! $grace ) { |
| 5193 |
$cached = get_transient( $cache_key ); |
| 5194 |
if ( false !== $cached ) { |
| 5195 |
return $cached; |
| 5196 |
} |
| 5197 |
} |
| 5198 |
|
| 5199 |
/* |
| 5200 |
* WordPress.org publishes checksums for plugins and for core, and none |
| 5201 |
* for themes: the address this method asked until now answers 404 for |
| 5202 |
* every theme, so no theme was ever compared with anything and they |
| 5203 |
* were all treated as themes from elsewhere. What WordPress.org does |
| 5204 |
* publish is the package of each version, so the checksums are taken |
| 5205 |
* from there: the package is downloaded once per version, each file in |
| 5206 |
* it is hashed straight from the archive, nothing is unpacked to disk, |
| 5207 |
* and the result is kept for a month. |
| 5208 |
*/ |
| 5209 |
if ( '' === (string) $version || ! preg_match( '/^[A-Za-z0-9._-]+$/', (string) $slug ) ) { |
| 5210 |
return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) ); |
| 5211 |
} |
| 5212 |
|
| 5213 |
if ( ! class_exists( 'ZipArchive' ) ) { |
| 5214 |
return new WP_Error( 'no_zip', __( 'the zip extension of PHP is missing on this server', 'vigilante' ) ); |
| 5215 |
} |
| 5216 |
|
| 5217 |
if ( ! function_exists( 'download_url' ) ) { |
| 5218 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 5219 |
} |
| 5220 |
|
| 5221 |
$package = download_url( |
| 5222 |
sprintf( 'https://downloads.wordpress.org/theme/%s.%s.zip', rawurlencode( $slug ), rawurlencode( (string) $version ) ), |
| 5223 |
30 |
| 5224 |
); |
| 5225 |
|
| 5226 |
if ( is_wp_error( $package ) ) { |
| 5227 |
$data = $package->get_error_data(); |
| 5228 |
|
| 5229 |
// WordPress.org has no such theme or no such version: a theme from |
| 5230 |
// somewhere else. Anything other than a 404 is a failed download, |
| 5231 |
// and is reported as that instead of passing for a theme with no |
| 5232 |
// official copy. |
| 5233 |
if ( is_array( $data ) && isset( $data['code'] ) && 404 === (int) $data['code'] ) { |
| 5234 |
if ( ! $grace ) { |
| 5235 |
set_transient( $cache_key, 'not_found', 12 * HOUR_IN_SECONDS ); |
| 5236 |
} |
| 5237 |
return new WP_Error( 'not_found', __( 'Checksums not available', 'vigilante' ) ); |
| 5238 |
} |
| 5239 |
|
| 5240 |
return $package; |
| 5241 |
} |
| 5242 |
|
| 5243 |
$checksums = array(); |
| 5244 |
$archive = new ZipArchive(); |
| 5245 |
|
| 5246 |
if ( true === $archive->open( $package ) ) { |
| 5247 |
$prefix = $slug . '/'; |
| 5248 |
$max_entry = 16 * MB_IN_BYTES; |
| 5249 |
|
| 5250 |
for ( $i = 0; $i < $archive->numFiles; $i++ ) { |
| 5251 |
$entry = $archive->statIndex( $i ); |
| 5252 |
$name = is_array( $entry ) ? (string) $entry['name'] : ''; |
| 5253 |
|
| 5254 |
// Everything in the package lives under the folder of the theme. |
| 5255 |
// Folders, anything outside it and any path with a ".." |
| 5256 |
// segment are not files of the theme. |
| 5257 |
if ( '' === $name || '/' === substr( $name, -1 ) || 0 !== strpos( $name, $prefix ) || preg_match( '#(?:^|/)\.\.(?:/|$)#', $name ) ) { |
| 5258 |
continue; |
| 5259 |
} |
| 5260 |
|
| 5261 |
// Read up to a limit: an entry larger than this is not code, |
| 5262 |
// and is left out of the comparison instead of being loaded |
| 5263 |
// whole into memory. |
| 5264 |
$content = $archive->getFromIndex( $i, $max_entry + 1 ); |
| 5265 |
|
| 5266 |
if ( false === $content ) { |
| 5267 |
$checksums = array(); |
| 5268 |
break; |
| 5269 |
} |
| 5270 |
|
| 5271 |
if ( strlen( $content ) > $max_entry ) { |
| 5272 |
continue; |
| 5273 |
} |
| 5274 |
|
| 5275 |
$checksums[ substr( $name, strlen( $prefix ) ) ] = array( |
| 5276 |
'md5' => md5( $content ), |
| 5277 |
'sha256' => hash( 'sha256', $content ), |
| 5278 |
); |
| 5279 |
} |
| 5280 |
|
| 5281 |
$archive->close(); |
| 5282 |
} |
| 5283 |
|
| 5284 |
wp_delete_file( $package ); |
| 5285 |
|
| 5286 |
if ( empty( $checksums ) ) { |
| 5287 |
return new WP_Error( 'no_checksums', __( 'the package from WordPress.org could not be read', 'vigilante' ) ); |
| 5288 |
} |
| 5289 |
|
| 5290 |
// The package of a published version does not change, so it is kept |
| 5291 |
// for a month (never during grace). |
| 5292 |
if ( ! $grace ) { |
| 5293 |
set_transient( $cache_key, $checksums, MONTH_IN_SECONDS ); |
| 5294 |
} |
| 5295 |
|
| 5296 |
return $checksums; |
| 5297 |
} |
| 5298 |
|
| 5299 |
/** |
| 5300 |
* Scan uploads directory for suspicious files |
| 5301 |
* |
| 5302 |
* @return array Array with 'suspicious' and 'extra' sub-arrays. |
| 5303 |
*/ |
| 5304 |
private function scan_uploads() { |
| 5305 |
$found = array( |
| 5306 |
'suspicious' => array(), |
| 5307 |
'extra' => array(), |
| 5308 |
'errors' => array(), |
| 5309 |
); |
| 5310 |
$upload_dir = wp_upload_dir(); |
| 5311 |
$base_dir = $upload_dir['basedir']; |
| 5312 |
$files_checked = 0; |
| 5313 |
|
| 5314 |
// The walk used to stop after 10,000 files without saying so, and a |
| 5315 |
// media library passes that with a few thousand images. Now it goes on |
| 5316 |
// for as long as its share of the scan time lasts, and says so when it |
| 5317 |
// is cut short. The share keeps a huge library from leaving the core |
| 5318 |
// and the plugins without their turn. |
| 5319 |
$deadline = $this->scan_start_time > 0 ? $this->scan_start_time + ( 0.4 * $this->max_scan_time ) : 0; |
| 5320 |
|
| 5321 |
// Executable extensions that should never be in uploads |
| 5322 |
$dangerous_extensions = self::php_extensions(); |
| 5323 |
|
| 5324 |
// Files that change how PHP runs in the folder they are in. |
| 5325 |
$php_config_files = array( '.user.ini', 'php.ini' ); |
| 5326 |
|
| 5327 |
// Scripts a server can be told to run through CGI. |
| 5328 |
$script_extensions = array( 'cgi', 'pl', 'py', 'sh', 'shtml' ); |
| 5329 |
|
| 5330 |
if ( ! is_dir( $base_dir ) ) { |
| 5331 |
return $found; |
| 5332 |
} |
| 5333 |
|
| 5334 |
try { |
| 5335 |
foreach ( self::walk_folder( $base_dir ) as $file ) { |
| 5336 |
// Check global time limit |
| 5337 |
if ( $this->is_time_exceeded() ) { |
| 5338 |
break; |
| 5339 |
} |
| 5340 |
|
| 5341 |
if ( $file->isDir() ) { |
| 5342 |
if ( ! $file->isReadable() ) { |
| 5343 |
$found['errors'][] = self::unreadable_folder_error( $file->getPathname() ); |
| 5344 |
} |
| 5345 |
continue; |
| 5346 |
} |
| 5347 |
|
| 5348 |
// Check the share of the scan time (every 500 files) |
| 5349 |
$files_checked++; |
| 5350 |
if ( $deadline > 0 && 0 === $files_checked % 500 && microtime( true ) > $deadline ) { |
| 5351 |
$found['errors'][] = sprintf( |
| 5352 |
/* translators: %s: Number of files */ |
| 5353 |
__( 'The uploads folder was not checked completely: the time for it ran out after %s files.', 'vigilante' ), |
| 5354 |
number_format_i18n( $files_checked ) |
| 5355 |
); |
| 5356 |
break; |
| 5357 |
} |
| 5358 |
|
| 5359 |
$file_path = $file->getPathname(); |
| 5360 |
$basename = basename( $file_path ); |
| 5361 |
$extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ); |
| 5362 |
$relative = str_replace( ABSPATH, '', $file_path ); |
| 5363 |
|
| 5364 |
// Skip excluded paths |
| 5365 |
if ( $this->is_path_excluded( $file_path ) ) { |
| 5366 |
continue; |
| 5367 |
} |
| 5368 |
|
| 5369 |
// 1. Check for PHP files in uploads (most important security check) |
| 5370 |
if ( in_array( $extension, $dangerous_extensions, true ) ) { |
| 5371 |
// Silence-is-golden placeholders are dropped by WordPress and many |
| 5372 |
// plugins into upload subfolders to block directory listings. |
| 5373 |
// Whitelist by content so an attacker can't bypass the rule with |
| 5374 |
// a payload named index.php. |
| 5375 |
if ( $this->is_silence_golden_file( $file_path ) ) { |
| 5376 |
continue; |
| 5377 |
} |
| 5378 |
|
| 5379 |
$reason = __( 'PHP file found in uploads directory', 'vigilante' ); |
| 5380 |
|
| 5381 |
// Scan content for specific suspicious patterns |
| 5382 |
$pattern = $this->search_file( $file_path ); |
| 5383 |
if ( $pattern ) { |
| 5384 |
/* translators: %s: Suspicious pattern found */ |
| 5385 |
$reason = sprintf( __( 'PHP in uploads with suspicious code: %s', 'vigilante' ), $pattern ); |
| 5386 |
} |
| 5387 |
|
| 5388 |
$found['suspicious'][] = array( |
| 5389 |
'file' => $relative, |
| 5390 |
'type' => 'php_in_uploads', |
| 5391 |
'reason' => $reason, |
| 5392 |
); |
| 5393 |
continue; |
| 5394 |
} |
| 5395 |
|
| 5396 |
// 2. Check for double extensions (image.php.jpg, file.phtml.png) |
| 5397 |
// The second extension can be anything: a script renamed by a |
| 5398 |
// malware cleaner (shell.php.suspected) or numbered (shell.php.1) |
| 5399 |
// is as much of a finding as one dressed up as an image. |
| 5400 |
if ( preg_match( '/\.(' . implode( '|', $dangerous_extensions ) . ')\.[a-z0-9_~-]{1,16}$/i', $basename ) ) { |
| 5401 |
$found['suspicious'][] = array( |
| 5402 |
'file' => $relative, |
| 5403 |
'type' => 'double_extension', |
| 5404 |
'reason' => __( 'Double extension detected (possible disguised executable)', 'vigilante' ), |
| 5405 |
); |
| 5406 |
continue; |
| 5407 |
} |
| 5408 |
|
| 5409 |
// 2b. Files that change how PHP runs in this folder |
| 5410 |
if ( in_array( strtolower( $basename ), $php_config_files, true ) ) { |
| 5411 |
$found['suspicious'][] = array( |
| 5412 |
'file' => $relative, |
| 5413 |
'type' => 'php_config_in_uploads', |
| 5414 |
'reason' => __( 'PHP configuration file in uploads', 'vigilante' ), |
| 5415 |
); |
| 5416 |
continue; |
| 5417 |
} |
| 5418 |
|
| 5419 |
// 2c. Scripts in other languages |
| 5420 |
if ( in_array( $extension, $script_extensions, true ) ) { |
| 5421 |
$found['extra'][] = array( |
| 5422 |
'file' => $relative, |
| 5423 |
'type' => 'script_in_uploads', |
| 5424 |
'reason' => __( 'Script file in uploads', 'vigilante' ), |
| 5425 |
); |
| 5426 |
continue; |
| 5427 |
} |
| 5428 |
|
| 5429 |
// 3. Check for .htaccess files in uploads |
| 5430 |
// Read content to classify (see classify_htaccess_in_uploads()) |
| 5431 |
if ( '.htaccess' === $basename ) { |
| 5432 |
$htaccess_result = $this->classify_htaccess_in_uploads( $file_path, $relative ); |
| 5433 |
|
| 5434 |
// One that only denies access, or does nothing, is not a finding. |
| 5435 |
if ( 'none' !== $htaccess_result['category'] ) { |
| 5436 |
$found[ $htaccess_result['category'] ][] = $htaccess_result['item']; |
| 5437 |
} |
| 5438 |
} |
| 5439 |
} |
| 5440 |
} catch ( Exception $e ) { |
| 5441 |
$found['errors'][] = self::unreadable_folder_error( $base_dir ); |
| 5442 |
} |
| 5443 |
|
| 5444 |
return $found; |
| 5445 |
} |
| 5446 |
|
| 5447 |
/** |
| 5448 |
* Classify a .htaccess file found in uploads directory |
| 5449 |
* |
| 5450 |
* The file is read the way Apache reads it: continued lines are joined, |
| 5451 |
* comments are dropped and each directive is looked at on its own. That |
| 5452 |
* replaces a handful of expressions run over the whole text, which took a |
| 5453 |
* commented line for a live one and the rule that forbids PHP for one that |
| 5454 |
* runs it, and let through the directives that turn CGI on. |
| 5455 |
* |
| 5456 |
* - Something that makes the server run code: suspicious. |
| 5457 |
* - Only directives that deny or switch something off: not reported. |
| 5458 |
* - Rewrite rules of its own and nothing that denies PHP: extra, because in |
| 5459 |
* that folder they replace the rules of the site root, the one that |
| 5460 |
* blocks PHP in uploads included (measured on Apache, 3 Oct 2026). |
| 5461 |
* - Anything else: extra, with what it contains. |
| 5462 |
* |
| 5463 |
* @param string $file_path Absolute file path. |
| 5464 |
* @param string $relative Relative file path for display. |
| 5465 |
* @return array Array with 'category' ('suspicious', 'extra' or 'none') and 'item' data. |
| 5466 |
*/ |
| 5467 |
private function classify_htaccess_in_uploads( $file_path, $relative ) { |
| 5468 |
$content = false; |
| 5469 |
|
| 5470 |
if ( filesize( $file_path ) < 65536 ) { // Only read files < 64KB |
| 5471 |
$content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 5472 |
} |
| 5473 |
|
| 5474 |
$item = array( |
| 5475 |
'file' => $relative, |
| 5476 |
'type' => 'htaccess_in_uploads', |
| 5477 |
); |
| 5478 |
|
| 5479 |
// A file that cannot be read cannot be vouched for. |
| 5480 |
if ( false === $content ) { |
| 5481 |
$item['reason'] = __( '.htaccess file in uploads directory that could not be read', 'vigilante' ); |
| 5482 |
|
| 5483 |
return array( |
| 5484 |
'category' => 'suspicious', |
| 5485 |
'item' => $item, |
| 5486 |
); |
| 5487 |
} |
| 5488 |
|
| 5489 |
$facts = self::read_htaccess_rules( $content ); |
| 5490 |
|
| 5491 |
if ( ! empty( $facts['runs'] ) ) { |
| 5492 |
$item['reason'] = self::htaccess_run_reason( $facts ); |
| 5493 |
|
| 5494 |
return array( |
| 5495 |
'category' => 'suspicious', |
| 5496 |
'item' => $item, |
| 5497 |
); |
| 5498 |
} |
| 5499 |
|
| 5500 |
if ( $facts['rewrite'] && ! $facts['denies_php'] ) { |
| 5501 |
$item['reason'] = __( '.htaccess in uploads with rewrite rules of its own: in this folder they replace the ones of the site root, the rule that blocks PHP in uploads included', 'vigilante' ); |
| 5502 |
|
| 5503 |
return array( |
| 5504 |
'category' => 'extra', |
| 5505 |
'item' => $item, |
| 5506 |
); |
| 5507 |
} |
| 5508 |
|
| 5509 |
// Empty, or nothing in it that does not deny or switch something off: |
| 5510 |
// it protects the folder, or does nothing, and is not a finding. |
| 5511 |
if ( empty( $facts['other'] ) ) { |
| 5512 |
return array( |
| 5513 |
'category' => 'none', |
| 5514 |
'item' => null, |
| 5515 |
); |
| 5516 |
} |
| 5517 |
|
| 5518 |
$kinds = array( |
| 5519 |
'access' => __( 'access rules', 'vigilante' ), |
| 5520 |
'rewrite' => __( 'rewrite rules', 'vigilante' ), |
| 5521 |
'headers' => __( 'cache or header rules', 'vigilante' ), |
| 5522 |
'other' => __( 'other rules', 'vigilante' ), |
| 5523 |
); |
| 5524 |
|
| 5525 |
/* translators: %s: Summary of rules found in the .htaccess file */ |
| 5526 |
$item['reason'] = sprintf( __( '.htaccess in uploads (likely from plugin). Contains: %s', 'vigilante' ), implode( ', ', array_intersect_key( $kinds, $facts['other'] ) ) ); |
| 5527 |
|
| 5528 |
return array( |
| 5529 |
'category' => 'extra', |
| 5530 |
'item' => $item, |
| 5531 |
); |
| 5532 |
} |
| 5533 |
|
| 5534 |
/** |
| 5535 |
* The reason shown for a .htaccess that makes the server run code |
| 5536 |
* |
| 5537 |
* @param array $facts What read_htaccess_rules() found. |
| 5538 |
* @return string |
| 5539 |
*/ |
| 5540 |
private static function htaccess_run_reason( $facts ) { |
| 5541 |
$labels = array( |
| 5542 |
'run' => __( 'makes the server run files of this folder as code', 'vigilante' ), |
| 5543 |
'load' => __( 'loads a file on every PHP request', 'vigilante' ), |
| 5544 |
'rewrite' => __( 'sends requests to a PHP file', 'vigilante' ), |
| 5545 |
'cgi' => __( 'turns on CGI or server-side includes', 'vigilante' ), |
| 5546 |
); |
| 5547 |
|
| 5548 |
/* translators: %s: What the rule does */ |
| 5549 |
return sprintf( __( '.htaccess with a rule that affects how code runs: %s', 'vigilante' ), implode( '; ', array_intersect_key( $labels, $facts['runs'] ) ) ); |
| 5550 |
} |
| 5551 |
|
| 5552 |
/** |
| 5553 |
* What a section of a .htaccess says about the directives inside it |
| 5554 |
* |
| 5555 |
* @param string $name Section name, in lower case. |
| 5556 |
* @param string $args What follows the name, as written. |
| 5557 |
* @return string '' when it changes nothing (<RequireAll>), 'rewrite' for |
| 5558 |
* <IfModule mod_rewrite.c>, 'maybe' for any other test of |
| 5559 |
* what the server has, 'files-php' for a <Files> or |
| 5560 |
* <FilesMatch> that names PHP files, 'files' for one that |
| 5561 |
* names other files, and 'condition' for the rest (<Limit>, |
| 5562 |
* <If>, <RequireAny>...), which apply to some requests only. |
| 5563 |
*/ |
| 5564 |
private static function htaccess_section_kind( $name, $args ) { |
| 5565 |
$args = trim( rtrim( trim( $args ), '>' ) ); |
| 5566 |
|
| 5567 |
if ( 'requireall' === $name ) { |
| 5568 |
return ''; |
| 5569 |
} |
| 5570 |
|
| 5571 |
if ( 'ifmodule' === $name ) { |
| 5572 |
return in_array( strtolower( trim( $args, '"\' ' ) ), array( 'mod_rewrite.c', 'rewrite_module' ), true ) ? 'rewrite' : 'maybe'; |
| 5573 |
} |
| 5574 |
|
| 5575 |
if ( in_array( $name, array( 'ifdefine', 'ifversion' ), true ) ) { |
| 5576 |
return 'maybe'; |
| 5577 |
} |
| 5578 |
|
| 5579 |
if ( 'files' === $name || 'filesmatch' === $name ) { |
| 5580 |
$regex = 'filesmatch' === $name; |
| 5581 |
|
| 5582 |
if ( ! $regex && '~' === substr( $args, 0, 1 ) ) { |
| 5583 |
$regex = true; |
| 5584 |
$args = trim( substr( $args, 1 ) ); |
| 5585 |
} |
| 5586 |
|
| 5587 |
return true === self::extension_pattern_names_php( trim( $args, '"\'' ), $regex, false ) ? 'files-php' : 'files'; |
| 5588 |
} |
| 5589 |
|
| 5590 |
return 'condition'; |
| 5591 |
} |
| 5592 |
|
| 5593 |
/** |
| 5594 |
* Whether a pattern that goes only by the extension of a file names the PHP ones |
| 5595 |
* |
| 5596 |
* "Every PHP file" cannot be asked of a sample name. A sample is public, |
| 5597 |
* so a section can be written for it and nothing else, and an expression |
| 5598 |
* can name every file but one ("all but shell.php") in more ways than can |
| 5599 |
* be tried. So the pattern has to be of a kind that cannot tell one name |
| 5600 |
* from another: any name, a dot and a list of extensions, to the end. In |
| 5601 |
* that kind there is nothing to make an exception with, and whether .php |
| 5602 |
* is on the list is then a fair question for a sample. Anything else is |
| 5603 |
* not taken for a denial of PHP, which only means the .htaccess is listed. |
| 5604 |
* |
| 5605 |
* @param string $pattern Pattern, without its quotes. |
| 5606 |
* @param bool $regex Whether it is an expression (FilesMatch, "Files ~", |
| 5607 |
* RewriteRule) or a wildcard (Files). |
| 5608 |
* @param bool $nocase Whether it ignores case. |
| 5609 |
* @return bool|null True when it names every .php file, false when it goes |
| 5610 |
* by the extension and .php is not one of them, null |
| 5611 |
* when it is not of that kind. |
| 5612 |
*/ |
| 5613 |
private static function extension_pattern_names_php( $pattern, $regex, $nocase ) { |
| 5614 |
if ( ! $regex ) { |
| 5615 |
if ( 1 !== preg_match( '/^\*\.[a-z0-9*?]+$/i', $pattern ) ) { |
| 5616 |
return null; |
| 5617 |
} |
| 5618 |
|
| 5619 |
return function_exists( 'fnmatch' ) ? fnmatch( $pattern, 'x.php' ) : '*.php' === $pattern; |
| 5620 |
} |
| 5621 |
|
| 5622 |
$rest = $pattern; |
| 5623 |
|
| 5624 |
if ( 0 === stripos( $rest, '(?i)' ) ) { |
| 5625 |
$rest = substr( $rest, 4 ); |
| 5626 |
$nocase = true; |
| 5627 |
} |
| 5628 |
|
| 5629 |
// "Any name" in front, written out or left out, and then a dot, the |
| 5630 |
// extensions and the end, written out or left out too. The extensions |
| 5631 |
// are letters, digits, groups, alternatives and classes of letters and |
| 5632 |
// digits: no dot, no "any character", nothing negated and nothing |
| 5633 |
// that looks around. |
| 5634 |
$rest = preg_replace( '/^\^?\.[*+]/', '', $rest, 1 ); |
| 5635 |
|
| 5636 |
if ( 1 !== preg_match( '/^\\\\\.(?:[a-z0-9|?*+)]|\\\\d|\[[a-z0-9-]+\]|\((?:\?i?:)?)+\$?$/i', (string) $rest ) ) { |
| 5637 |
return null; |
| 5638 |
} |
| 5639 |
|
| 5640 |
// The dot has to be there: with "?" or "*" after it, what follows |
| 5641 |
// would be looked for anywhere in the name. And an alternative |
| 5642 |
// outside a group is a second pattern with no dot in front of it, |
| 5643 |
// which goes by the name again. |
| 5644 |
if ( 1 === preg_match( '/^\\\\\.[?*+]/', $rest ) ) { |
| 5645 |
return null; |
| 5646 |
} |
| 5647 |
|
| 5648 |
$depth = 0; |
| 5649 |
|
| 5650 |
for ( $i = 0, $length = strlen( $rest ); $i < $length; $i++ ) { |
| 5651 |
if ( '(' === $rest[ $i ] ) { |
| 5652 |
$depth++; |
| 5653 |
} elseif ( ')' === $rest[ $i ] ) { |
| 5654 |
$depth--; |
| 5655 |
} |
| 5656 |
|
| 5657 |
if ( $depth < 0 || ( 0 === $depth && '|' === $rest[ $i ] ) ) { |
| 5658 |
return null; |
| 5659 |
} |
| 5660 |
} |
| 5661 |
|
| 5662 |
if ( 0 !== $depth ) { |
| 5663 |
return null; |
| 5664 |
} |
| 5665 |
|
| 5666 |
$expression = '~' . str_replace( '~', '\\~', $pattern ) . '~' . ( $nocase ? 'i' : '' ); |
| 5667 |
|
| 5668 |
foreach ( array( 'x.php', 'a/b/x.php' ) as $sample ) { |
| 5669 |
$matched = @preg_match( $expression, $sample ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A pattern Apache would refuse names nothing. |
| 5670 |
|
| 5671 |
if ( false === $matched ) { |
| 5672 |
return null; |
| 5673 |
} |
| 5674 |
|
| 5675 |
if ( 0 === $matched ) { |
| 5676 |
return false; |
| 5677 |
} |
| 5678 |
} |
| 5679 |
|
| 5680 |
return true; |
| 5681 |
} |
| 5682 |
|
| 5683 |
/** |
| 5684 |
* Whether a RewriteRule that refuses requests refuses every PHP file |
| 5685 |
* |
| 5686 |
* A pattern with a "!" in front refuses what it does not match, so it |
| 5687 |
* refuses PHP when it goes by the extension and .php is not one of them |
| 5688 |
* ("!\.(jpe?g|png)$": everything but the images). |
| 5689 |
* |
| 5690 |
* @param string $pattern Pattern of the rule. |
| 5691 |
* @param string $flags Flags of the rule, in lower case. |
| 5692 |
* @return bool |
| 5693 |
*/ |
| 5694 |
private static function rewrite_rule_refuses_php( $pattern, $flags ) { |
| 5695 |
$pattern = trim( $pattern, '"\'' ); |
| 5696 |
$negated = '' !== $pattern && '!' === $pattern[0]; |
| 5697 |
|
| 5698 |
if ( $negated ) { |
| 5699 |
$pattern = substr( $pattern, 1 ); |
| 5700 |
} |
| 5701 |
|
| 5702 |
if ( '' === $pattern ) { |
| 5703 |
return false; |
| 5704 |
} |
| 5705 |
|
| 5706 |
$names = self::extension_pattern_names_php( $pattern, true, (bool) preg_match( '/[\[,]\s*(?:nc|nocase)\s*[,\]]/', $flags ) ); |
| 5707 |
|
| 5708 |
return $negated ? false === $names : true === $names; |
| 5709 |
} |
| 5710 |
|
| 5711 |
/** |
| 5712 |
* What the directives of a .htaccess do, as far as running code goes |
| 5713 |
* |
| 5714 |
* @param string $content Content of the file. |
| 5715 |
* @return array { |
| 5716 |
* @type array $runs Ways it makes the server run code, as keys: |
| 5717 |
* 'run', 'load', 'rewrite', 'cgi'. |
| 5718 |
* @type array $other Kinds of directive that neither run code nor |
| 5719 |
* only deny, as keys: 'access', 'rewrite', |
| 5720 |
* 'headers', 'other'. |
| 5721 |
* @type bool $rewrite Whether it has rewrite directives of its own. |
| 5722 |
* @type bool $denies_php Whether it denies access to every PHP file |
| 5723 |
* itself, for every request and whatever the |
| 5724 |
* server has loaded. |
| 5725 |
* } |
| 5726 |
*/ |
| 5727 |
private static function read_htaccess_rules( $content ) { |
| 5728 |
$facts = array( |
| 5729 |
'runs' => array(), |
| 5730 |
'other' => array(), |
| 5731 |
'rewrite' => false, |
| 5732 |
'denies_php' => false, |
| 5733 |
); |
| 5734 |
|
| 5735 |
// A line that ends in a backslash goes on in the next one, and Apache |
| 5736 |
// joins the two before it looks at anything else, comments included. |
| 5737 |
$content = str_replace( array( "\r\n", "\r" ), "\n", (string) $content ); |
| 5738 |
$content = preg_replace( '/(?<!\\\\)\\\\\n/', '', $content ); |
| 5739 |
|
| 5740 |
// "script" as a word of its own: cgi-script and php-script are |
| 5741 |
// handlers that run code, application/javascript is a kind of file. |
| 5742 |
$scripts = '/php|cgi|(?<![a-z])script|lsapi|server-parsed|proxy:|python-program/'; |
| 5743 |
$php_target = '/\.(?:ph(?:p\d*|tml|t|ar|ps)|inc)(?![a-z0-9])/'; |
| 5744 |
$containers = array(); |
| 5745 |
$cgi_handler = false; |
| 5746 |
$execcgi_on = false; |
| 5747 |
$execcgi_off = false; |
| 5748 |
|
| 5749 |
foreach ( explode( "\n", (string) $content ) as $line ) { |
| 5750 |
$line = trim( $line ); |
| 5751 |
|
| 5752 |
if ( '' === $line || '#' === $line[0] ) { |
| 5753 |
continue; |
| 5754 |
} |
| 5755 |
|
| 5756 |
// Sections are remembered, to know what a directive inside them |
| 5757 |
// applies to (see htaccess_section_kind()). |
| 5758 |
if ( '<' === $line[0] ) { |
| 5759 |
if ( preg_match( '/^<\s*\//', $line ) ) { |
| 5760 |
array_pop( $containers ); |
| 5761 |
} elseif ( preg_match( '/^<\s*([A-Za-z]+)\b(.*)$/', $line, $section ) ) { |
| 5762 |
$containers[] = self::htaccess_section_kind( strtolower( $section[1] ), $section[2] ); |
| 5763 |
} |
| 5764 |
continue; |
| 5765 |
} |
| 5766 |
|
| 5767 |
if ( ! preg_match( '/^([A-Za-z_]+)\s*(.*)$/', $line, $parts ) ) { |
| 5768 |
$facts['other']['other'] = true; |
| 5769 |
continue; |
| 5770 |
} |
| 5771 |
|
| 5772 |
$name = strtolower( $parts[1] ); |
| 5773 |
$args = trim( $parts[2] ); |
| 5774 |
$low = strtolower( $args ); |
| 5775 |
$words = preg_split( '/\s+/', $low, -1, PREG_SPLIT_NO_EMPTY ); |
| 5776 |
$first = isset( $words[0] ) ? trim( $words[0], '"\'' ) : ''; |
| 5777 |
|
| 5778 |
// The sections around this line that put a condition on it. |
| 5779 |
$inside = array_values( array_diff( $containers, array( '' ) ) ); |
| 5780 |
|
| 5781 |
switch ( $name ) { |
| 5782 |
case 'sethandler': |
| 5783 |
case 'addhandler': |
| 5784 |
case 'addtype': |
| 5785 |
case 'forcetype': |
| 5786 |
if ( preg_match( $scripts, $first ) ) { |
| 5787 |
// "AddHandler cgi-script" next to "Options -ExecCGI" is |
| 5788 |
// the old recipe that hands every script to CGI and |
| 5789 |
// forbids CGI. Which of the two it is gets decided |
| 5790 |
// once the whole file has been read. |
| 5791 |
if ( 'addhandler' === $name && false !== strpos( $first, 'cgi' ) ) { |
| 5792 |
$cgi_handler = true; |
| 5793 |
} else { |
| 5794 |
$facts['runs']['run'] = true; |
| 5795 |
} |
| 5796 |
} elseif ( ! in_array( $first, array( 'none', 'default-handler', 'text/plain', 'application/octet-stream' ), true ) ) { |
| 5797 |
$facts['other']['other'] = true; |
| 5798 |
} |
| 5799 |
break; |
| 5800 |
|
| 5801 |
case 'php_flag': |
| 5802 |
case 'php_admin_flag': |
| 5803 |
case 'php_value': |
| 5804 |
case 'php_admin_value': |
| 5805 |
$value = isset( $words[1] ) ? trim( $words[1], '"\'' ) : ''; |
| 5806 |
|
| 5807 |
if ( 'engine' === $first ) { |
| 5808 |
if ( ! in_array( $value, array( 'off', '0', 'false', 'no' ), true ) ) { |
| 5809 |
$facts['runs']['run'] = true; |
| 5810 |
} |
| 5811 |
} elseif ( in_array( $first, array( 'auto_prepend_file', 'auto_append_file' ), true ) ) { |
| 5812 |
if ( '' !== $value && 'none' !== $value ) { |
| 5813 |
$facts['runs']['load'] = true; |
| 5814 |
} |
| 5815 |
} else { |
| 5816 |
$facts['other']['other'] = true; |
| 5817 |
} |
| 5818 |
break; |
| 5819 |
|
| 5820 |
case 'options': |
| 5821 |
foreach ( $words as $option ) { |
| 5822 |
// Only a ban on CGI that holds for everybody counts as one. |
| 5823 |
if ( '-execcgi' === $option && empty( $inside ) ) { |
| 5824 |
$execcgi_off = true; |
| 5825 |
} |
| 5826 |
|
| 5827 |
if ( '-' === $option[0] || 'none' === $option ) { |
| 5828 |
continue; |
| 5829 |
} |
| 5830 |
|
| 5831 |
$option = ltrim( $option, '+' ); |
| 5832 |
|
| 5833 |
if ( in_array( $option, array( 'execcgi', 'includes', 'all' ), true ) ) { |
| 5834 |
$facts['runs']['cgi'] = true; |
| 5835 |
$execcgi_on = true; |
| 5836 |
} else { |
| 5837 |
$facts['other']['other'] = true; |
| 5838 |
} |
| 5839 |
} |
| 5840 |
break; |
| 5841 |
|
| 5842 |
case 'addoutputfilter': |
| 5843 |
case 'setoutputfilter': |
| 5844 |
case 'addoutputfilterbytype': |
| 5845 |
if ( false !== strpos( $low, 'includes' ) ) { |
| 5846 |
$facts['runs']['cgi'] = true; |
| 5847 |
} else { |
| 5848 |
$facts['other']['other'] = true; |
| 5849 |
} |
| 5850 |
break; |
| 5851 |
|
| 5852 |
case 'action': |
| 5853 |
$facts['runs']['cgi'] = true; |
| 5854 |
break; |
| 5855 |
|
| 5856 |
case 'xbithack': |
| 5857 |
// Server-side includes for every file with the execute bit. |
| 5858 |
if ( 'off' !== $first ) { |
| 5859 |
$facts['runs']['cgi'] = true; |
| 5860 |
} |
| 5861 |
break; |
| 5862 |
|
| 5863 |
case 'deny': |
| 5864 |
case 'require': |
| 5865 |
$denies = 'deny' === $name ? ( 'from' === $first && isset( $words[1] ) && 'all' === $words[1] ) : ( 'all' === $first && isset( $words[1] ) && 'denied' === $words[1] ); |
| 5866 |
|
| 5867 |
if ( ! $denies || in_array( 'condition', $inside, true ) ) { |
| 5868 |
$facts['other']['access'] = true; |
| 5869 |
} elseif ( ! array_diff( $inside, array( 'rewrite', 'files-php' ) ) ) { |
| 5870 |
// For every request, on any server, and for every file |
| 5871 |
// or for the PHP ones. A denial that depends on a |
| 5872 |
// module being there, or that names other files, still |
| 5873 |
// protects, but it does not make up for rewrite rules |
| 5874 |
// that switch off the ones of the site root. |
| 5875 |
$facts['denies_php'] = true; |
| 5876 |
} |
| 5877 |
break; |
| 5878 |
|
| 5879 |
case 'order': |
| 5880 |
case 'indexignore': |
| 5881 |
break; |
| 5882 |
|
| 5883 |
case 'allow': |
| 5884 |
case 'satisfy': |
| 5885 |
case 'authtype': |
| 5886 |
case 'authname': |
| 5887 |
case 'authuserfile': |
| 5888 |
case 'authgroupfile': |
| 5889 |
$facts['other']['access'] = true; |
| 5890 |
break; |
| 5891 |
|
| 5892 |
case 'rewriteengine': |
| 5893 |
case 'rewritebase': |
| 5894 |
case 'rewriteoptions': |
| 5895 |
$facts['rewrite'] = true; |
| 5896 |
break; |
| 5897 |
|
| 5898 |
case 'rewritecond': |
| 5899 |
// A condition makes the rule after it apply to some |
| 5900 |
// requests and not to others, a denial included. |
| 5901 |
$facts['rewrite'] = true; |
| 5902 |
$facts['other']['rewrite'] = true; |
| 5903 |
break; |
| 5904 |
|
| 5905 |
case 'rewriterule': |
| 5906 |
$facts['rewrite'] = true; |
| 5907 |
|
| 5908 |
$rule = preg_split( '/\s+/', $args, 3 ); |
| 5909 |
$pattern = isset( $rule[0] ) ? $rule[0] : ''; |
| 5910 |
$target = isset( $rule[1] ) ? strtolower( trim( $rule[1], '"\'' ) ) : ''; |
| 5911 |
$flags = isset( $rule[2] ) ? strtolower( $rule[2] ) : ''; |
| 5912 |
|
| 5913 |
if ( preg_match( '/[\[,]\s*h=/', $flags ) || preg_match( '/[\[,]\s*t=[^,\]]*(?:php|cgi|(?<![a-z])script)/', $flags ) ) { |
| 5914 |
// A handler or a type forced from the rule itself. |
| 5915 |
$facts['runs']['run'] = true; |
| 5916 |
} elseif ( '-' === $target && preg_match( '/[\[,]\s*(?:f|forbidden|g|gone)\s*[,\]]/', $flags ) ) { |
| 5917 |
// Rewrites nothing and refuses the request. It stands |
| 5918 |
// for the rule of the site root only when it refuses |
| 5919 |
// every PHP file, for every request. |
| 5920 |
if ( ! array_diff( $inside, array( 'rewrite' ) ) && self::rewrite_rule_refuses_php( $pattern, $flags ) ) { |
| 5921 |
$facts['denies_php'] = true; |
| 5922 |
} else { |
| 5923 |
$facts['other']['rewrite'] = true; |
| 5924 |
} |
| 5925 |
} elseif ( '-' === $target ) { |
| 5926 |
// Rewrites nothing and lets the request through, which |
| 5927 |
// can be the way to keep one file out of the denial |
| 5928 |
// that follows: it is not part of a file that only |
| 5929 |
// protects. |
| 5930 |
$facts['other']['rewrite'] = true; |
| 5931 |
} elseif ( preg_match( $php_target, $target ) && '/index.php' !== $target ) { |
| 5932 |
$facts['runs']['rewrite'] = true; |
| 5933 |
} else { |
| 5934 |
$facts['other']['rewrite'] = true; |
| 5935 |
} |
| 5936 |
break; |
| 5937 |
|
| 5938 |
case 'header': |
| 5939 |
case 'requestheader': |
| 5940 |
case 'expiresactive': |
| 5941 |
case 'expiresbytype': |
| 5942 |
case 'expiresdefault': |
| 5943 |
case 'fileetag': |
| 5944 |
$facts['other']['headers'] = true; |
| 5945 |
break; |
| 5946 |
|
| 5947 |
default: |
| 5948 |
$facts['other']['other'] = true; |
| 5949 |
} |
| 5950 |
} |
| 5951 |
|
| 5952 |
if ( $cgi_handler && ( $execcgi_on || ! $execcgi_off ) ) { |
| 5953 |
$facts['runs']['cgi'] = true; |
| 5954 |
} |
| 5955 |
|
| 5956 |
return $facts; |
| 5957 |
} |
| 5958 |
|
| 5959 |
/** |
| 5960 |
* Check content for suspicious patterns |
| 5961 |
* |
| 5962 |
* @param string $content File content. |
| 5963 |
* @return bool |
| 5964 |
*/ |
| 5965 |
private function has_suspicious_content( $content ) { |
| 5966 |
return (bool) $this->detect_suspicious_pattern( $content ); |
| 5967 |
} |
| 5968 |
|
| 5969 |
/** |
| 5970 |
* Detect specific suspicious pattern in file content |
| 5971 |
* |
| 5972 |
* Two detection levels: |
| 5973 |
* - Standard (strict=false): for uploads where ANY PHP is already suspicious. |
| 5974 |
* Single-function matches like dangerous functions, superglobals are enough. |
| 5975 |
* - Strict (strict=true): for plugins/themes without checksums where PHP is expected. |
| 5976 |
* Only flags clear obfuscation combos to avoid false positives on legitimate code. |
| 5977 |
* |
| 5978 |
* Patterns are loaded from an external JSON file (scan-patterns.json) |
| 5979 |
* with base64-encoded needles to prevent WAF/antimalware false positives |
| 5980 |
* on the scanner file itself. |
| 5981 |
* |
| 5982 |
* @param string $content File content. |
| 5983 |
* @param bool $strict Use strict mode (fewer, higher-confidence patterns). |
| 5984 |
* @return string|false The pattern found, or false. |
| 5985 |
*/ |
| 5986 |
private function detect_suspicious_pattern( $content, $strict = false ) { |
| 5987 |
|
| 5988 |
if ( $strict ) { |
| 5989 |
return $this->detect_strict_suspicious_pattern( $content ); |
| 5990 |
} |
| 5991 |
|
| 5992 |
$patterns_data = $this->load_scan_patterns(); |
| 5993 |
if ( empty( $patterns_data['standard_patterns'] ) ) { |
| 5994 |
return false; |
| 5995 |
} |
| 5996 |
|
| 5997 |
// Standard mode: broad detection for uploads and known-extra files |
| 5998 |
foreach ( $patterns_data['standard_patterns'] as $encoded_needle => $label ) { |
| 5999 |
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding pattern definitions, not user input. |
| 6000 |
$needle = base64_decode( $encoded_needle ); |
| 6001 |
if ( $this->needle_present( $content, $needle ) ) { |
| 6002 |
// The labels live in the pattern file, in English: "$_GET access" |
| 6003 |
// becomes "$_GET", which needs no translation. |
| 6004 |
return preg_replace( '/ access$/', '', (string) $label ); |
| 6005 |
} |
| 6006 |
} |
| 6007 |
|
| 6008 |
return $this->detect_common_pattern( $content ); |
| 6009 |
} |
| 6010 |
|
| 6011 |
/** |
| 6012 |
* The three checks both levels of the search share |
| 6013 |
* |
| 6014 |
* @param string $content File content. |
| 6015 |
* @return string|false The pattern found, or false. |
| 6016 |
*/ |
| 6017 |
private function detect_common_pattern( $content ) { |
| 6018 |
// preg_replace with the /e modifier (arbitrary code execution, |
| 6019 |
// deprecated). The modifier is looked for on the same line and within |
| 6020 |
// 300 characters of the opening quote. With ".*" every preg_replace of |
| 6021 |
// a long line cost a pass over the whole line: 88 seconds for a file |
| 6022 |
// of 500 KB made for it, measured without the PCRE compiler. |
| 6023 |
if ( $this->found( '/preg_replace\s*+\(\s*+[\'"][^\n]{0,300}?\/e[\'"]/i', $content ) ) { |
| 6024 |
return __( 'preg_replace with the /e modifier, which runs code', 'vigilante' ); |
| 6025 |
} |
| 6026 |
|
| 6027 |
// Long hex-encoded strings (obfuscated payloads). Eleven escapes in a |
| 6028 |
// row and not "ten or more after the first": asked to take them all, |
| 6029 |
// PCRE gave up on a run of some 12,000, and a search that gave up |
| 6030 |
// read as "nothing found". The longer the payload, the less it was |
| 6031 |
// found. |
| 6032 |
if ( $this->found( '/(?:\\\\x[0-9a-f]{2}){11}/i', $content ) ) { |
| 6033 |
return __( 'a long string written in hexadecimal', 'vigilante' ); |
| 6034 |
} |
| 6035 |
|
| 6036 |
// Heavily concatenated chr() calls (char-by-char obfuscation) |
| 6037 |
if ( $this->found( '/chr\s*+\(\s*+\d++\s*+\)\s*+\.\s*+chr\s*+\(\s*+\d++\s*+\)\s*+\.\s*+chr/i', $content ) ) { |
| 6038 |
return __( 'text built character by character with chr()', 'vigilante' ); |
| 6039 |
} |
| 6040 |
|
| 6041 |
return false; |
| 6042 |
} |
| 6043 |
|
| 6044 |
/** |
| 6045 |
* Strict suspicious pattern detection for plugins/themes without checksums |
| 6046 |
* |
| 6047 |
* Only flags high-confidence obfuscation combos that are almost certainly malware. |
| 6048 |
* Individual functions are normal in plugins and are not flagged. |
| 6049 |
* |
| 6050 |
* @param string $content File content. |
| 6051 |
* @return string|false The pattern found, or false. |
| 6052 |
*/ |
| 6053 |
private function detect_strict_suspicious_pattern( $content ) { |
| 6054 |
|
| 6055 |
$patterns_data = $this->load_scan_patterns(); |
| 6056 |
if ( empty( $patterns_data['strict_fragments'] ) ) { |
| 6057 |
return false; |
| 6058 |
} |
| 6059 |
|
| 6060 |
// Decode fragment names from JSON |
| 6061 |
$fragments = array(); |
| 6062 |
foreach ( $patterns_data['strict_fragments'] as $key => $encoded ) { |
| 6063 |
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode |
| 6064 |
$fragments[ $key ] = base64_decode( $encoded ); |
| 6065 |
} |
| 6066 |
|
| 6067 |
$ev = $fragments['ev'] ?? ''; |
| 6068 |
$b6 = $fragments['b6'] ?? ''; |
| 6069 |
$gz = $fragments['gz'] ?? ''; |
| 6070 |
$gu = $fragments['gu'] ?? ''; |
| 6071 |
$sr = $fragments['sr'] ?? ''; |
| 6072 |
$hb = $fragments['hb'] ?? ''; |
| 6073 |
$as = $fragments['as'] ?? ''; |
| 6074 |
$ss = $fragments['ss'] ?? ''; |
| 6075 |
$cf = $fragments['cf'] ?? ''; |
| 6076 |
|
| 6077 |
// Obfuscation combos: dangerous function wrapping decoded content. |
| 6078 |
// The labels name the two functions side by side and not one inside |
| 6079 |
// the other. Written the way the code is written, a label was itself |
| 6080 |
// a match: the results of a scan, kept in a cache that stores options |
| 6081 |
// as PHP files under wp-content, would have been the next finding. |
| 6082 |
// The possessive quantifiers ("*+") change nothing in what matches, |
| 6083 |
// because what follows each run of spaces is never a space. They keep |
| 6084 |
// PCRE from walking a long run back one space at a time, which is |
| 6085 |
// where it gives up when it runs without its compiler. |
| 6086 |
$obfuscation_combos = array( |
| 6087 |
'/' . $ev . '\s*+\(\s*+' . $b6 . '\s*+\(/i' => $ev . '() + ' . $b6 . '()', |
| 6088 |
'/' . $ev . '\s*+\(\s*+' . $gz . '\s*+\(/i' => $ev . '() + ' . $gz . '()', |
| 6089 |
'/' . $ev . '\s*+\(\s*+' . $gu . '\s*+\(/i' => $ev . '() + ' . $gu . '()', |
| 6090 |
'/' . $ev . '\s*+\(\s*+' . $sr . '\s*+\(/i' => $ev . '() + ' . $sr . '()', |
| 6091 |
'/' . $ev . '\s*+\(\s*+' . $hb . '\s*+\(/i' => $ev . '() + ' . $hb . '()', |
| 6092 |
'/' . $as . '\s*+\(\s*+' . $b6 . '\s*+\(/i' => $as . '() + ' . $b6 . '()', |
| 6093 |
'/' . $ev . '\s*+\(\s*+\$[a-z_]++\s*+\(/i' => $ev . '() + $variable()', |
| 6094 |
'/' . $ev . '\s*+\(\s*+' . $ss . '\s*+\(/i' => $ev . '() + ' . $ss . '()', |
| 6095 |
); |
| 6096 |
|
| 6097 |
foreach ( $obfuscation_combos as $regex => $label ) { |
| 6098 |
if ( $this->found( $regex, $content ) ) { |
| 6099 |
return $label; |
| 6100 |
} |
| 6101 |
} |
| 6102 |
|
| 6103 |
// The plain backdoor, with nothing to hide: data from the request |
| 6104 |
// handed straight to code or to the system, a function the request |
| 6105 |
// chooses, or a file included from another server. The names come |
| 6106 |
// from the list the concatenation check below uses, minus the decoders, |
| 6107 |
// so they are not written here either. That list is decoded once, for both. |
| 6108 |
$dangerous_names = array(); |
| 6109 |
if ( ! empty( $patterns_data['dangerous_names'] ) ) { |
| 6110 |
foreach ( $patterns_data['dangerous_names'] as $encoded_name ) { |
| 6111 |
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode |
| 6112 |
$dangerous_names[] = base64_decode( $encoded_name ); |
| 6113 |
} |
| 6114 |
} |
| 6115 |
|
| 6116 |
$runners = array_merge( array_filter( array( $ev, $as ) ), array_diff( $dangerous_names, array( $b6, $gz, $gu, $sr, $hb ) ) ); |
| 6117 |
|
| 6118 |
// These three read as code what PHP reads as code, so they are |
| 6119 |
// searched with the comments of the file turned into blank space |
| 6120 |
// (blank_comments()). A comment that quotes one of them ("never do |
| 6121 |
// this: ...") is then not a finding, and a comment slipped between a |
| 6122 |
// name and its parenthesis hides nothing. The first two cannot match |
| 6123 |
// without a superglobal of the request, so only the files that name |
| 6124 |
// one are prepared that way, about one in twenty. The third is looked |
| 6125 |
// for in the file as it is and checked again that way when it matches. |
| 6126 |
$code = null; |
| 6127 |
|
| 6128 |
if ( $this->found( '/\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\b/', $content ) ) { |
| 6129 |
$code = $this->blank_comments( $content ); |
| 6130 |
|
| 6131 |
// When PHP cannot be asked what is a comment, the file is |
| 6132 |
// searched as it is: a comment may then be reported, and nothing |
| 6133 |
// that runs is left out. |
| 6134 |
$subject = false === $code ? $content : $code; |
| 6135 |
|
| 6136 |
if ( ! empty( $runners ) ) { |
| 6137 |
$names = implode( '|', array_map( 'preg_quote', $runners ) ); |
| 6138 |
// What the visitor sends, when it is not just being asked about |
| 6139 |
// with isset() or empty(). The path of an uploaded file is not in |
| 6140 |
// the list: handing it to a command is how images get processed. |
| 6141 |
$from_request = '(?<!isset\()(?<!isset\( )(?<!empty\()(?<!empty\( )(?:\$_(?:GET|POST|REQUEST|COOKIE)\b|\$_SERVER\s*\[\s*[\'"]HTTP_)'; |
| 6142 |
|
| 6143 |
// The possessive quantifiers keep a file made of spaces from |
| 6144 |
// costing a try per space. |
| 6145 |
if ( $this->found( '/(?<![a-z0-9_>:$])(?:' . $names . ')\s*+\(\s*+[^;\n]{0,80}?' . $from_request . '/i', $subject ) ) { |
| 6146 |
return __( 'data from the request is run as code or as a command', 'vigilante' ); |
| 6147 |
} |
| 6148 |
} |
| 6149 |
|
| 6150 |
if ( $this->found( '/\$_(?:GET|POST|REQUEST|COOKIE)\s*+\[[^\]\n]{1,60}\]\s*+\(/', $subject ) ) { |
| 6151 |
return __( 'a function chosen by the request is called', 'vigilante' ); |
| 6152 |
} |
| 6153 |
} |
| 6154 |
|
| 6155 |
if ( $this->found_in_code( '/(?<![a-z0-9_>$])(?:include|require)(?:_once)?\s*+(?:\(\s*+)?[\'"]https?:\/\//i', $content, $code ) ) { |
| 6156 |
return __( 'a file from another server is included', 'vigilante' ); |
| 6157 |
} |
| 6158 |
|
| 6159 |
// Deprecated dynamic function constructor, nearly always malicious in modern code |
| 6160 |
if ( ! empty( $cf ) && $this->needle_present( $content, $cf ) ) { |
| 6161 |
// The name alone, for the same reason as the labels above. |
| 6162 |
return rtrim( $cf, '(' ); |
| 6163 |
} |
| 6164 |
|
| 6165 |
// Remote fetch piped into unserialize: a PHP object-injection / supply-chain |
| 6166 |
// vector seen in trojanized or nulled plugins (download a payload from a |
| 6167 |
// remote URL and unserialize it). Requires a REAL unserialize() call |
| 6168 |
// (not maybe_unserialize() / igbinary_unserialize(), which are common and |
| 6169 |
// safe) sitting CLOSE TO a remote-fetch call. Earlier releases only |
| 6170 |
// checked that both strings appeared somewhere in the file, which |
| 6171 |
// false-positived on legitimate code using both in unrelated methods |
| 6172 |
// (e.g. a theme reading a transient with maybe_unserialize() while |
| 6173 |
// fetching its public IP with wp_remote_get()). |
| 6174 |
// |
| 6175 |
// The search runs on a copy of the file in lower case, with |
| 6176 |
// expressions that tell the case apart, and only when the file names |
| 6177 |
// the function at all. PCRE without its compiler looks ahead for the |
| 6178 |
// first letter of an expression in both cases before each try, and |
| 6179 |
// when one of the two is not in the file that look runs to the end of |
| 6180 |
// it. pattern_near() asks once per match, so a file with thousands of |
| 6181 |
// them cost thousands of passes (26 seconds for 8 MB, measured). With |
| 6182 |
// one case to look for, each look stops at the next candidate. |
| 6183 |
$us = self::lower_ascii( $fragments['us'] ?? '' ); |
| 6184 |
if ( '' !== $us && false !== stripos( $content, $us ) ) { |
| 6185 |
$lower = self::lower_ascii( $content ); |
| 6186 |
$us_regex = '/(?<![a-z0-9_])' . preg_quote( $us, '/' ) . '\s*+\(/'; |
| 6187 |
$remote_fetchers = array( |
| 6188 |
$fragments['wr'] ?? '', // wp_remote_get |
| 6189 |
$fragments['rb'] ?? '', // wp_remote_retrieve_body |
| 6190 |
$fragments['ce'] ?? '', // curl_exec |
| 6191 |
); |
| 6192 |
foreach ( $remote_fetchers as $rf ) { |
| 6193 |
if ( '' === $rf ) { |
| 6194 |
continue; |
| 6195 |
} |
| 6196 |
$rf_regex = '/(?<![a-z0-9_])' . preg_quote( self::lower_ascii( $rf ), '/' ) . '\s*+\(/'; |
| 6197 |
if ( $this->pattern_near( $lower, $us_regex, $rf_regex, 600 ) ) { |
| 6198 |
/* translators: 1: Function that downloads, 2: Function that unserializes */ |
| 6199 |
return sprintf( __( 'data downloaded from another server is unserialized (%1$s and %2$s)', 'vigilante' ), $rf, $us ); |
| 6200 |
} |
| 6201 |
} |
| 6202 |
|
| 6203 |
// file_get_contents() is treated separately from the fetchers |
| 6204 |
// above: those are unambiguously remote, while file_get_contents |
| 6205 |
// is PHP's most common LOCAL file reader, and reading a local |
| 6206 |
// path right next to unserialize() is a legitimate pattern |
| 6207 |
// (settings import/export, PSR-6 file caches shipped in premium |
| 6208 |
// plugins, which have no wp.org checksums so this heuristic is |
| 6209 |
// their only filter). It only acts as a remote fetcher when its |
| 6210 |
// argument is a URL, so the combo additionally requires a |
| 6211 |
// remote-scheme literal near the call before it fires. |
| 6212 |
$fg = $fragments['fg'] ?? ''; |
| 6213 |
if ( '' !== $fg ) { |
| 6214 |
$fg_regex = '/(?<![a-z0-9_])' . preg_quote( self::lower_ascii( $fg ), '/' ) . '\s*+\(/'; |
| 6215 |
$scheme_regex = '/(?:https?|ftps?):\/\/|php:\/\/input/'; |
| 6216 |
|
| 6217 |
if ( $this->pattern_near( $lower, $us_regex, $fg_regex, 600 ) |
| 6218 |
&& $this->pattern_near( $lower, $fg_regex, $scheme_regex, 600 ) ) { |
| 6219 |
/* translators: 1: Function that downloads, 2: Function that unserializes */ |
| 6220 |
return sprintf( __( 'data downloaded from another server is unserialized (%1$s and %2$s)', 'vigilante' ), $fg, $us ); |
| 6221 |
} |
| 6222 |
} |
| 6223 |
} |
| 6224 |
|
| 6225 |
$common = $this->detect_common_pattern( $content ); |
| 6226 |
if ( $common ) { |
| 6227 |
return $common; |
| 6228 |
} |
| 6229 |
|
| 6230 |
// Dangerous function names built from string concatenation: a variable |
| 6231 |
// given a name in pieces and then called. The assignments are taken |
| 6232 |
// one at a time and the calls are looked up in one pass. Collecting |
| 6233 |
// every assignment of the file first, and then searching the whole |
| 6234 |
// file again for each one, took memory and time that grew with the |
| 6235 |
// square of the file. |
| 6236 |
$split = array(); |
| 6237 |
$capped = false; |
| 6238 |
$offset = 0; |
| 6239 |
|
| 6240 |
while ( true ) { |
| 6241 |
// A variable, "=" and a quoted piece followed by a dot: where a |
| 6242 |
// string put together from pieces starts. |
| 6243 |
$start = $this->next_match( '/\$([a-z_]\w*)\s*+=\s*+(?=["\'][a-z0-9_]*+["\']\s*+\.)/i', $content, $offset ); |
| 6244 |
|
| 6245 |
if ( null === $start ) { |
| 6246 |
break; |
| 6247 |
} |
| 6248 |
|
| 6249 |
// The pieces are taken one by one, however many there are: a name |
| 6250 |
// can be padded with as many empty ones as its author likes, and |
| 6251 |
// asking PCRE for the whole chain at once is what made it give up. |
| 6252 |
$position = $start[0][1] + strlen( $start[0][0] ); |
| 6253 |
$combined = ''; |
| 6254 |
$closed = false; |
| 6255 |
|
| 6256 |
while ( true ) { |
| 6257 |
$piece = $this->next_match( '/\G["\']([a-z0-9_]*+)["\']\s*+(\.\s*+|;)?+/i', $content, $position ); |
| 6258 |
|
| 6259 |
if ( null === $piece ) { |
| 6260 |
break; |
| 6261 |
} |
| 6262 |
|
| 6263 |
// No name on the list is this long: past it, the letters are |
| 6264 |
// not kept, and the chain is still walked to its end. |
| 6265 |
if ( strlen( $combined ) <= 64 ) { |
| 6266 |
$combined .= self::lower_ascii( $piece[1][0] ); |
| 6267 |
} |
| 6268 |
|
| 6269 |
$position = $piece[0][1] + strlen( $piece[0][0] ); |
| 6270 |
$after = isset( $piece[2] ) && $piece[2][1] >= 0 ? $piece[2][0] : ''; |
| 6271 |
|
| 6272 |
if ( '' === $after || ';' === $after ) { |
| 6273 |
$closed = ';' === $after; |
| 6274 |
break; |
| 6275 |
} |
| 6276 |
} |
| 6277 |
|
| 6278 |
$offset = max( $position, $start[0][1] + 1 ); |
| 6279 |
|
| 6280 |
if ( $closed && in_array( $combined, $dangerous_names, true ) && ! isset( $split[ $start[1][0] ] ) ) { |
| 6281 |
$split[ $start[1][0] ] = $combined; |
| 6282 |
|
| 6283 |
if ( count( $split ) >= 500 ) { |
| 6284 |
$capped = true; |
| 6285 |
break; |
| 6286 |
} |
| 6287 |
} |
| 6288 |
} |
| 6289 |
|
| 6290 |
if ( ! empty( $split ) ) { |
| 6291 |
$offset = 0; |
| 6292 |
|
| 6293 |
while ( true ) { |
| 6294 |
$call = $this->next_match( '/\$([a-z_]\w*)\s*+\(/i', $content, $offset ); |
| 6295 |
|
| 6296 |
if ( null === $call ) { |
| 6297 |
break; |
| 6298 |
} |
| 6299 |
|
| 6300 |
if ( isset( $split[ $call[1][0] ] ) ) { |
| 6301 |
/* translators: %s: Function name */ |
| 6302 |
return sprintf( __( 'call to %s() with its name split in pieces', 'vigilante' ), $split[ $call[1][0] ] ); |
| 6303 |
} |
| 6304 |
|
| 6305 |
$offset = $call[0][1] + strlen( $call[0][0] ); |
| 6306 |
} |
| 6307 |
|
| 6308 |
// More names in pieces than a file has any reason to hold, and |
| 6309 |
// not every one was followed: not searched, rather than clean. |
| 6310 |
if ( $capped ) { |
| 6311 |
$this->search_failed = true; |
| 6312 |
} |
| 6313 |
} |
| 6314 |
|
| 6315 |
return false; |
| 6316 |
} |
| 6317 |
|
| 6318 |
/** |
| 6319 |
* Whether a scan needle is present as a real token rather than glued inside |
| 6320 |
* a longer identifier. |
| 6321 |
* |
| 6322 |
* Function-call needles (an identifier followed by "(") are matched with a |
| 6323 |
* left word boundary, so "file_get_contents(" no longer matches |
| 6324 |
* "wpcom_vip_file_get_contents(", "unserialize(" no longer matches |
| 6325 |
* "maybe_unserialize(", and "eval(" no longer matches "retrieval(". Needles |
| 6326 |
* that are not plain identifiers (such as the "$_GET[" superglobal probes) |
| 6327 |
* keep a plain case-insensitive substring search. |
| 6328 |
* |
| 6329 |
* @param string $content File content. |
| 6330 |
* @param string $needle Decoded needle (e.g. "eval(", "$_GET["). |
| 6331 |
* @return bool |
| 6332 |
*/ |
| 6333 |
private function needle_present( $content, $needle ) { |
| 6334 |
if ( '' === $needle ) { |
| 6335 |
return false; |
| 6336 |
} |
| 6337 |
if ( preg_match( '/^[a-z_][a-z0-9_]*\($/i', $needle ) ) { |
| 6338 |
$fn = rtrim( $needle, '(' ); |
| 6339 |
return $this->found( '/(?<![a-z0-9_])' . preg_quote( $fn, '/' ) . '\s*+\(/i', $content ); |
| 6340 |
} |
| 6341 |
return stripos( $content, $needle ) !== false; |
| 6342 |
} |
| 6343 |
|
| 6344 |
/** |
| 6345 |
* Whether two patterns both occur within $window bytes of each other. |
| 6346 |
* |
| 6347 |
* Used to require that correlated malware signals (for example a remote |
| 6348 |
* fetch and an unserialize call) sit in the same code path instead of |
| 6349 |
* merely coexisting somewhere in the file, which was a false-positive |
| 6350 |
* source when only their presence was checked. |
| 6351 |
* |
| 6352 |
* @param string $content File content. |
| 6353 |
* @param string $regex_a First anchored pattern (with delimiters and flags). |
| 6354 |
* @param string $regex_b Second anchored pattern (with delimiters and flags). |
| 6355 |
* @param int $window Maximum byte distance between a match of each. |
| 6356 |
* @return bool |
| 6357 |
*/ |
| 6358 |
private function pattern_near( $content, $regex_a, $regex_b, $window ) { |
| 6359 |
// Both lists come in order, so they are walked together: the match |
| 6360 |
// that is behind moves on, and no pair is compared twice. Every match |
| 6361 |
// of one used to be compared with every match of the other. |
| 6362 |
$a = $this->next_match( $regex_a, $content, 0 ); |
| 6363 |
$b = $this->next_match( $regex_b, $content, 0 ); |
| 6364 |
|
| 6365 |
while ( null !== $a && null !== $b ) { |
| 6366 |
if ( abs( $a[0][1] - $b[0][1] ) <= $window ) { |
| 6367 |
return true; |
| 6368 |
} |
| 6369 |
|
| 6370 |
if ( $a[0][1] < $b[0][1] ) { |
| 6371 |
$a = $this->next_match( $regex_a, $content, $a[0][1] + 1 ); |
| 6372 |
} else { |
| 6373 |
$b = $this->next_match( $regex_b, $content, $b[0][1] + 1 ); |
| 6374 |
} |
| 6375 |
} |
| 6376 |
|
| 6377 |
return false; |
| 6378 |
} |
| 6379 |
|
| 6380 |
/** |
| 6381 |
* A text with its capital letters from A to Z in lower case, and nothing else |
| 6382 |
* |
| 6383 |
* strtolower() follows the language of the server until PHP 8.2, and in |
| 6384 |
* Turkish a capital I does not become an i: a function name written in |
| 6385 |
* capitals would not be found there. |
| 6386 |
* |
| 6387 |
* @param string $text Text. |
| 6388 |
* @return string |
| 6389 |
*/ |
| 6390 |
private static function lower_ascii( $text ) { |
| 6391 |
return strtr( (string) $text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz' ); |
| 6392 |
} |
| 6393 |
|
| 6394 |
/** |
| 6395 |
* Whether an expression matches, without taking a search that gave up for a "no" |
| 6396 |
* |
| 6397 |
* preg_match() answers false, and not 0, when PCRE stops before the end |
| 6398 |
* because a long run used up the stack of its compiler or its |
| 6399 |
* backtracking limit. Read as "nothing found", that is a file that passes |
| 6400 |
* as clean without having been searched. Measured on what was published |
| 6401 |
* until now: a hexadecimal string of some 12,000 characters made the |
| 6402 |
* expression that looks for hexadecimal strings give up. Every expression |
| 6403 |
* of the search goes through here or through next_match(), and a file on |
| 6404 |
* which one of them gave up is reported as not searched (search_file()). |
| 6405 |
* |
| 6406 |
* @param string $regex Expression, with delimiters and flags. |
| 6407 |
* @param string $content Text to search. |
| 6408 |
* @return bool |
| 6409 |
*/ |
| 6410 |
private function found( $regex, $content ) { |
| 6411 |
$result = preg_match( $regex, $content ); |
| 6412 |
|
| 6413 |
if ( false === $result ) { |
| 6414 |
$this->search_failed = true; |
| 6415 |
} |
| 6416 |
|
| 6417 |
return 1 === $result; |
| 6418 |
} |
| 6419 |
|
| 6420 |
/** |
| 6421 |
* The next match of an expression from a position on |
| 6422 |
* |
| 6423 |
* For the checks that walk every match. Asked for one at a time they take |
| 6424 |
* no memory to speak of, where preg_match_all() held every match of the |
| 6425 |
* file at once. |
| 6426 |
* |
| 6427 |
* @param string $regex Expression, with delimiters and flags. |
| 6428 |
* @param string $content Text to search. |
| 6429 |
* @param int $offset Where to start. |
| 6430 |
* @return array|null The match as PREG_OFFSET_CAPTURE gives it, or null. |
| 6431 |
*/ |
| 6432 |
private function next_match( $regex, $content, $offset ) { |
| 6433 |
$match = array(); |
| 6434 |
$result = preg_match( $regex, $content, $match, PREG_OFFSET_CAPTURE, $offset ); |
| 6435 |
|
| 6436 |
if ( false === $result ) { |
| 6437 |
$this->search_failed = true; |
| 6438 |
} |
| 6439 |
|
| 6440 |
return 1 === $result ? $match : null; |
| 6441 |
} |
| 6442 |
|
| 6443 |
/** |
| 6444 |
* Whether an expression matches in the code of a file and not only in its comments |
| 6445 |
* |
| 6446 |
* @param string $regex Expression, with delimiters and flags. |
| 6447 |
* @param string $content File content. |
| 6448 |
* @param string|false|null $code The content with its comments blanked. Worked |
| 6449 |
* out here the first time it is needed and kept |
| 6450 |
* by the caller; false when it cannot be. |
| 6451 |
* @return bool |
| 6452 |
*/ |
| 6453 |
private function found_in_code( $regex, $content, &$code ) { |
| 6454 |
if ( ! $this->found( $regex, $content ) ) { |
| 6455 |
return false; |
| 6456 |
} |
| 6457 |
|
| 6458 |
if ( null === $code ) { |
| 6459 |
$code = $this->blank_comments( $content ); |
| 6460 |
} |
| 6461 |
|
| 6462 |
// When PHP cannot be asked what is a comment, the match stands. |
| 6463 |
return false === $code || $this->found( $regex, $code ); |
| 6464 |
} |
| 6465 |
|
| 6466 |
/** |
| 6467 |
* The content of a PHP file with its comments turned into blank space |
| 6468 |
* |
| 6469 |
* PHP itself says what is a comment (token_get_all()), so nothing that |
| 6470 |
* runs can be taken for one: a "//" comment ends at "?>", a string that |
| 6471 |
* holds "/*" opens nothing, and so on, without a second reader of the |
| 6472 |
* language here to get any of it wrong. Each comment is replaced by as |
| 6473 |
* many spaces as it had characters, line breaks kept, so the result is as |
| 6474 |
* long as the original and what was on one line stays on one line. |
| 6475 |
* |
| 6476 |
* Three cases are left as they are, and then the match stands: |
| 6477 |
* |
| 6478 |
* - A file with a "<?" that is not "<?php" or "<?=". Whether PHP reads |
| 6479 |
* that as an opening tag depends on short_open_tag, which may not be |
| 6480 |
* set the same for the PHP that runs this scan as for the one that |
| 6481 |
* serves the site, and with it changes where the code starts and so |
| 6482 |
* what is a comment. |
| 6483 |
* - A comment that starts with "#[". PHP 8 reads that as an attribute and |
| 6484 |
* runs what follows it on the line; PHP 7 reads the whole line as a |
| 6485 |
* comment. |
| 6486 |
* - A file whose tokens would not fit in the memory that is left, or |
| 6487 |
* would take more than 64 MB. PHP returns one array per token, up to |
| 6488 |
* 450 bytes each, and a file can be written so that every byte is one: |
| 6489 |
* 400 KB of that took 180 MB with PHP 7.4 (measured). How many tokens |
| 6490 |
* there can be is counted first, without making them. |
| 6491 |
* - Any file, when PHP is set to read its source in another encoding |
| 6492 |
* (zend.multibyte). In Shift JIS, Big5 or GBK the second byte of a |
| 6493 |
* character can be a backslash, so where a string ends is not where |
| 6494 |
* it ends for a reader that goes byte by byte. |
| 6495 |
* |
| 6496 |
* @param string $content File content. |
| 6497 |
* @return string|false The content without its comments, or false when it |
| 6498 |
* cannot be told what is a comment. |
| 6499 |
*/ |
| 6500 |
private function blank_comments( $content ) { |
| 6501 |
if ( ! function_exists( 'token_get_all' ) || ini_get( 'zend.multibyte' ) || preg_match( '/<\?(?!php[ \t\r\n]|=)/i', $content ) ) { |
| 6502 |
return false; |
| 6503 |
} |
| 6504 |
|
| 6505 |
// No more tokens than this: each character that is not part of a |
| 6506 |
// word can be one, and so can each run of digits and each word. |
| 6507 |
$words = 0; |
| 6508 |
$others = preg_replace( '/[a-z_\x80-\xff][a-z0-9_\x80-\xff]*+|[0-9]++/i', '', $content, -1, $words ); |
| 6509 |
|
| 6510 |
if ( null === $others ) { |
| 6511 |
return false; |
| 6512 |
} |
| 6513 |
|
| 6514 |
$needed = 450 * ( strlen( $others ) + $words + 2 ) + strlen( $content ); |
| 6515 |
$limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) ); |
| 6516 |
unset( $others ); |
| 6517 |
|
| 6518 |
if ( $needed > 64 * MB_IN_BYTES || ( $limit > 0 && ( $limit - memory_get_usage() ) < $needed ) ) { |
| 6519 |
return false; |
| 6520 |
} |
| 6521 |
|
| 6522 |
$tokens = @token_get_all( $content ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- PHP 7 warns about what it cannot read in the file being scanned, such as a comment left open; the warning says nothing about this code. |
| 6523 |
$code = ''; |
| 6524 |
$length = 0; |
| 6525 |
|
| 6526 |
foreach ( $tokens as $token ) { |
| 6527 |
$text = is_array( $token ) ? $token[1] : $token; |
| 6528 |
$length += strlen( $text ); |
| 6529 |
|
| 6530 |
if ( is_array( $token ) && ( T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0] ) && 0 !== strpos( $text, '#[' ) ) { |
| 6531 |
$text = preg_replace( '/[^\n]/', ' ', $text ); |
| 6532 |
} |
| 6533 |
|
| 6534 |
$code .= $text; |
| 6535 |
} |
| 6536 |
|
| 6537 |
// The pieces have to add up to the file. If they do not, the |
| 6538 |
// positions cannot be trusted and neither can the result. |
| 6539 |
if ( strlen( $content ) !== $length || strlen( $code ) !== $length ) { |
| 6540 |
return false; |
| 6541 |
} |
| 6542 |
|
| 6543 |
return $code; |
| 6544 |
} |
| 6545 |
|
| 6546 |
/** |
| 6547 |
* Search one file for hidden code |
| 6548 |
* |
| 6549 |
* The one place where a file is read for that. Each scan used to read it |
| 6550 |
* by itself, and only when it was under 512,000 bytes: a larger file was |
| 6551 |
* not opened, and where the file was not going to be listed anyway (a |
| 6552 |
* plugin or a theme with no official copy) that was the same as calling |
| 6553 |
* it clean. Padding a file was enough to keep it out of the results. |
| 6554 |
* |
| 6555 |
* @param string $file_path Absolute path. |
| 6556 |
* @param bool $strict Use strict mode (see detect_suspicious_pattern()). |
| 6557 |
* @return string|false|null The pattern found, false when the file was |
| 6558 |
* searched and has none, or null when it could |
| 6559 |
* not be searched: it is larger than |
| 6560 |
* MAX_SEARCHED_BYTES, it cannot be read, or an |
| 6561 |
* expression gave up on it. The caller reports |
| 6562 |
* that instead of taking it for clean. |
| 6563 |
*/ |
| 6564 |
private function search_file( $file_path, $strict = false ) { |
| 6565 |
if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) { |
| 6566 |
return null; |
| 6567 |
} |
| 6568 |
|
| 6569 |
$size = filesize( $file_path ); |
| 6570 |
|
| 6571 |
if ( false === $size || $size > self::MAX_SEARCHED_BYTES ) { |
| 6572 |
return null; |
| 6573 |
} |
| 6574 |
|
| 6575 |
// One byte past the cap and no more, whatever the size said: a file |
| 6576 |
// that grew since it was measured is not read whole. |
| 6577 |
$content = file_get_contents( $file_path, false, null, 0, self::MAX_SEARCHED_BYTES + 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a local file to look for hidden code; nothing is written. |
| 6578 |
|
| 6579 |
if ( false === $content || strlen( $content ) > self::MAX_SEARCHED_BYTES ) { |
| 6580 |
return null; |
| 6581 |
} |
| 6582 |
|
| 6583 |
$this->search_failed = false; |
| 6584 |
$pattern = $this->detect_suspicious_pattern( $content, $strict ); |
| 6585 |
|
| 6586 |
if ( $pattern ) { |
| 6587 |
return $pattern; |
| 6588 |
} |
| 6589 |
|
| 6590 |
return $this->search_failed ? null : false; |
| 6591 |
} |
| 6592 |
|
| 6593 |
/** |
| 6594 |
* The finding for a PHP file with no official copy that could not be searched |
| 6595 |
* |
| 6596 |
* @param string $file_path Absolute path. |
| 6597 |
* @param string $relative Path for display. |
| 6598 |
* @return array |
| 6599 |
*/ |
| 6600 |
private function not_searched_item( $file_path, $relative ) { |
| 6601 |
$size = is_file( $file_path ) ? filesize( $file_path ) : false; |
| 6602 |
$reason = __( 'PHP file that could not be searched for hidden code', 'vigilante' ); |
| 6603 |
|
| 6604 |
if ( false !== $size && $size > self::MAX_SEARCHED_BYTES ) { |
| 6605 |
/* translators: %s: File size, such as 12 MB */ |
| 6606 |
$reason = sprintf( __( 'PHP file of %s, too large to be searched for hidden code', 'vigilante' ), size_format( $size ) ); |
| 6607 |
} |
| 6608 |
|
| 6609 |
return array( |
| 6610 |
'file' => $relative, |
| 6611 |
'type' => 'not_searched', |
| 6612 |
'reason' => $reason, |
| 6613 |
); |
| 6614 |
} |
| 6615 |
|
| 6616 |
/** |
| 6617 |
* Whether a file is a text file worth normalizing before the fallback hash |
| 6618 |
* comparison in hash_matches_published(). |
| 6619 |
* |
| 6620 |
* @param string $file_path Absolute path. |
| 6621 |
* @return bool |
| 6622 |
*/ |
| 6623 |
private function is_text_file( $file_path ) { |
| 6624 |
$text_ext = array( |
| 6625 |
'php', 'php3', 'php4', 'php5', 'php7', 'phtml', |
| 6626 |
'js', 'css', 'html', 'htm', 'xml', 'svg', |
| 6627 |
'txt', 'md', 'json', 'po', 'pot', 'yml', 'yaml', 'ini', 'csv', |
| 6628 |
); |
| 6629 |
return in_array( strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ), $text_ext, true ); |
| 6630 |
} |
| 6631 |
|
| 6632 |
/** |
| 6633 |
* Normalize a relative path for checksum-key comparison: forward slashes, |
| 6634 |
* no doubled slashes, no leading "./" or "/". Case is preserved because |
| 6635 |
* plugin and theme file systems are case-sensitive on most hosts. |
| 6636 |
* |
| 6637 |
* @param string $path Relative path. |
| 6638 |
* @return string |
| 6639 |
*/ |
| 6640 |
private function normalize_rel_path( $path ) { |
| 6641 |
$path = str_replace( '\\', '/', $path ); |
| 6642 |
$path = preg_replace( '#/+#', '/', $path ); |
| 6643 |
if ( 0 === strpos( $path, './' ) ) { |
| 6644 |
$path = substr( $path, 2 ); |
| 6645 |
} |
| 6646 |
return ltrim( $path, '/' ); |
| 6647 |
} |
| 6648 |
|
| 6649 |
/** |
| 6650 |
* Load scan patterns from external JSON file |
| 6651 |
* |
| 6652 |
* Patterns are stored in a JSON file with base64-encoded values |
| 6653 |
* to prevent hosting WAF/antimalware from flagging the scanner |
| 6654 |
* PHP file as suspicious. |
| 6655 |
* |
| 6656 |
* @return array Patterns data. |
| 6657 |
*/ |
| 6658 |
private function load_scan_patterns() { |
| 6659 |
static $cached = null; |
| 6660 |
|
| 6661 |
if ( null !== $cached ) { |
| 6662 |
return $cached; |
| 6663 |
} |
| 6664 |
|
| 6665 |
$file = VIGILANTE_INCLUDES_DIR . 'scan-patterns.json'; |
| 6666 |
|
| 6667 |
if ( ! file_exists( $file ) ) { |
| 6668 |
$cached = array(); |
| 6669 |
return $cached; |
| 6670 |
} |
| 6671 |
|
| 6672 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read, not remote. |
| 6673 |
$json = file_get_contents( $file ); |
| 6674 |
$cached = json_decode( $json, true ); |
| 6675 |
|
| 6676 |
if ( ! is_array( $cached ) ) { |
| 6677 |
$cached = array(); |
| 6678 |
} |
| 6679 |
|
| 6680 |
return $cached; |
| 6681 |
} |
| 6682 |
|
| 6683 |
/** |
| 6684 |
* Detect extra files in a directory that are not in checksums |
| 6685 |
* (potential backdoors injected into plugins/themes) |
| 6686 |
* |
| 6687 |
* When $suspicious_only is true (no checksums available), only files |
| 6688 |
* with suspicious code patterns are reported. This avoids flooding |
| 6689 |
* results with every PHP file from plugins/themes not on WordPress.org. |
| 6690 |
* |
| 6691 |
* @param string $directory Directory to scan. |
| 6692 |
* @param array $checksums Known checksums from WordPress.org (empty if unavailable). |
| 6693 |
* @param string $type 'plugin' or 'theme'. |
| 6694 |
* @param string $name Plugin or theme name. |
| 6695 |
* @param bool $suspicious_only Only report files with suspicious patterns. |
| 6696 |
* @return array Array with 'suspicious' and 'extra' sub-arrays. |
| 6697 |
*/ |
| 6698 |
private function detect_extra_files( $directory, $checksums, $type, $name, $suspicious_only = false ) { |
| 6699 |
$found = array( |
| 6700 |
'suspicious' => array(), |
| 6701 |
'extra' => array(), |
| 6702 |
'errors' => array(), |
| 6703 |
'php_files' => 0, |
| 6704 |
); |
| 6705 |
// Every PHP file is examined. Until now the walk stopped after 50, which |
| 6706 |
// in a plugin or theme with no official copy meant the first 50 files |
| 6707 |
// of the folder and nothing else. Only the list of plain extra files is |
| 6708 |
// bounded, so a folder full of generated PHP cannot fill the results; |
| 6709 |
// hidden code is reported wherever it is. |
| 6710 |
$max_listed = 500; |
| 6711 |
$listed = 0; |
| 6712 |
$truncated = false; |
| 6713 |
|
| 6714 |
// Only check PHP files for performance |
| 6715 |
$php_extensions = self::php_extensions(); |
| 6716 |
|
| 6717 |
// Pre-normalize the checksum keys once so path-shape differences |
| 6718 |
// (Windows backslashes, a leading "./", doubled slashes) don't make a |
| 6719 |
// known file look "extra" and get scanned or flagged. Case is preserved. |
| 6720 |
$known_normalized = array(); |
| 6721 |
foreach ( array_keys( $checksums ) as $known_file ) { |
| 6722 |
$known_normalized[ $this->normalize_rel_path( $known_file ) ] = true; |
| 6723 |
} |
| 6724 |
|
| 6725 |
try { |
| 6726 |
foreach ( self::walk_folder( $directory ) as $file ) { |
| 6727 |
if ( $this->is_time_exceeded() ) { |
| 6728 |
break; |
| 6729 |
} |
| 6730 |
|
| 6731 |
$file_path = $file->getPathname(); |
| 6732 |
|
| 6733 |
if ( $file->isDir() ) { |
| 6734 |
if ( ! $file->isReadable() ) { |
| 6735 |
$found['errors'][] = self::unreadable_folder_error( $file_path ); |
| 6736 |
} |
| 6737 |
continue; |
| 6738 |
} |
| 6739 |
|
| 6740 |
$extension = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ); |
| 6741 |
|
| 6742 |
// A .htaccess, .user.ini or php.ini that is not part of the |
| 6743 |
// published copy is looked at for what it makes the server do. |
| 6744 |
if ( in_array( strtolower( basename( $file_path ) ), array( '.htaccess', '.user.ini', 'php.ini' ), true ) ) { |
| 6745 |
$config_path = $this->normalize_rel_path( str_replace( str_replace( '\\', '/', $directory ) . '/', '', str_replace( '\\', '/', $file_path ) ) ); |
| 6746 |
|
| 6747 |
if ( ! isset( $known_normalized[ $config_path ] ) && ! $this->is_path_excluded( $file_path ) ) { |
| 6748 |
$config = self::server_config_finding( $file_path, str_replace( ABSPATH, '', $file_path ) ); |
| 6749 |
|
| 6750 |
if ( $config ) { |
| 6751 |
$found[ $config['category'] ][] = $config['item']; |
| 6752 |
} |
| 6753 |
} |
| 6754 |
continue; |
| 6755 |
} |
| 6756 |
|
| 6757 |
// Only check PHP files |
| 6758 |
if ( ! in_array( $extension, $php_extensions, true ) ) { |
| 6759 |
continue; |
| 6760 |
} |
| 6761 |
|
| 6762 |
// Get relative path within plugin/theme directory, normalized so |
| 6763 |
// path-shape quirks don't misclassify a known file as "extra". |
| 6764 |
$norm_path = str_replace( '\\', '/', $file_path ); |
| 6765 |
$norm_dir = str_replace( '\\', '/', $directory ); |
| 6766 |
$relative_to_dir = $this->normalize_rel_path( str_replace( $norm_dir . '/', '', $norm_path ) ); |
| 6767 |
|
| 6768 |
// Skip if file is in the checksums (it's known) |
| 6769 |
if ( isset( $known_normalized[ $relative_to_dir ] ) ) { |
| 6770 |
continue; |
| 6771 |
} |
| 6772 |
|
| 6773 |
// Skip excluded paths |
| 6774 |
if ( $this->is_path_excluded( $file_path ) ) { |
| 6775 |
continue; |
| 6776 |
} |
| 6777 |
|
| 6778 |
// Skip "Silence is golden" placeholder index.php files used by |
| 6779 |
// WordPress core and many plugins to prevent directory listings. |
| 6780 |
// The check is content-based — an attacker cannot bypass it by |
| 6781 |
// simply naming a payload file index.php. |
| 6782 |
if ( $this->is_silence_golden_file( $file_path ) ) { |
| 6783 |
continue; |
| 6784 |
} |
| 6785 |
|
| 6786 |
$found['php_files']++; |
| 6787 |
|
| 6788 |
$relative = str_replace( ABSPATH, '', $file_path ); |
| 6789 |
|
| 6790 |
// Check for suspicious content in extra files |
| 6791 |
// Use strict mode for plugins without checksums to avoid false positives |
| 6792 |
$pattern = $this->search_file( $file_path, $suspicious_only ); |
| 6793 |
|
| 6794 |
$item = array( |
| 6795 |
'file' => $relative, |
| 6796 |
$type => $name, |
| 6797 |
); |
| 6798 |
|
| 6799 |
if ( $pattern ) { |
| 6800 |
// Suspicious content: promote to suspicious category |
| 6801 |
$item['type'] = 'suspicious_' . $type; |
| 6802 |
|
| 6803 |
// With an official copy to compare with, the file is known |
| 6804 |
// not to belong to it. Without one it may well be the |
| 6805 |
// plugin's own, so it is not called injected. |
| 6806 |
if ( $suspicious_only ) { |
| 6807 |
/* translators: 1: Plugin or theme name, or the folder the file is in, 2: Suspicious pattern found */ |
| 6808 |
$item['reason'] = sprintf( __( 'File in %1$s with suspicious code: %2$s', 'vigilante' ), $name, $pattern ); |
| 6809 |
} else { |
| 6810 |
/* translators: 1: Plugin or theme name, 2: Suspicious pattern found */ |
| 6811 |
$item['reason'] = sprintf( __( 'File that is not part of %1$s, with suspicious code: %2$s', 'vigilante' ), $name, $pattern ); |
| 6812 |
} |
| 6813 |
|
| 6814 |
$found['suspicious'][] = $item; |
| 6815 |
} elseif ( ! $suspicious_only ) { |
| 6816 |
// No suspicious patterns and checksums available: report as extra |
| 6817 |
// Skipped in suspicious_only mode (no checksums) to avoid noise |
| 6818 |
if ( $listed >= $max_listed ) { |
| 6819 |
if ( ! $truncated ) { |
| 6820 |
$truncated = true; |
| 6821 |
$found['errors'][] = sprintf( |
| 6822 |
/* translators: 1: Plugin or theme name, 2: Number of files */ |
| 6823 |
__( '%1$s has more extra files than can be listed: only the first %2$d are shown.', 'vigilante' ), |
| 6824 |
$name, |
| 6825 |
$max_listed |
| 6826 |
); |
| 6827 |
} |
| 6828 |
continue; |
| 6829 |
} |
| 6830 |
|
| 6831 |
$listed++; |
| 6832 |
$item['type'] = 'extra_' . $type; |
| 6833 |
$item['reason'] = __( 'PHP file not present in original distribution', 'vigilante' ); |
| 6834 |
$found['extra'][] = $item; |
| 6835 |
} elseif ( null === $pattern ) { |
| 6836 |
// No official copy to compare with, and the search could |
| 6837 |
// not look inside either: said, not passed over. |
| 6838 |
$found['extra'][] = $this->not_searched_item( $file_path, $relative ); |
| 6839 |
} |
| 6840 |
} |
| 6841 |
} catch ( Exception $e ) { |
| 6842 |
$found['errors'][] = self::unreadable_folder_error( $directory ); |
| 6843 |
} |
| 6844 |
|
| 6845 |
return $found; |
| 6846 |
} |
| 6847 |
|
| 6848 |
/** |
| 6849 |
* Whether the given file is a trivial "Silence is golden" placeholder. |
| 6850 |
* |
| 6851 |
* WordPress core and most plugins drop an empty or near-empty index.php |
| 6852 |
* inside their directories to block directory listings on misconfigured |
| 6853 |
* servers. Those files trip the extra/suspicious detector even though |
| 6854 |
* they're harmless. We whitelist them by content (not by name) so an |
| 6855 |
* attacker cannot bypass the rule simply by calling a payload index.php. |
| 6856 |
* |
| 6857 |
* @param string $file_path Absolute path to the file being scanned. |
| 6858 |
* @return bool True when the file is a known harmless placeholder. |
| 6859 |
*/ |
| 6860 |
private function is_silence_golden_file( $file_path ) { |
| 6861 |
if ( 'index.php' !== basename( $file_path ) ) { |
| 6862 |
return false; |
| 6863 |
} |
| 6864 |
|
| 6865 |
// Cap to avoid reading large files just to check this. Real placeholders |
| 6866 |
// are always tiny (< 100 bytes); anything bigger isn't one. |
| 6867 |
$size = @filesize( $file_path ); |
| 6868 |
if ( false === $size || $size > 256 ) { |
| 6869 |
return false; |
| 6870 |
} |
| 6871 |
|
| 6872 |
$content = @file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 6873 |
if ( false === $content ) { |
| 6874 |
return false; |
| 6875 |
} |
| 6876 |
|
| 6877 |
// Every run of whitespace counts as one space, so the placeholder |
| 6878 |
// WordPress itself ships (the opening tag, a line break and the comment) |
| 6879 |
// matches the same entries as the one-line form. The comparison is |
| 6880 |
// still against the whole file: nothing can follow these few words, and |
| 6881 |
// no arrangement of them on several lines is code that runs. |
| 6882 |
$normalized = strtolower( trim( preg_replace( '/\s+/', ' ', $content ) ) ); |
| 6883 |
|
| 6884 |
$known = array( |
| 6885 |
'', |
| 6886 |
'<?php', |
| 6887 |
'<?php //silence is golden.', |
| 6888 |
'<?php // silence is golden.', |
| 6889 |
'<?php //silence is golden', |
| 6890 |
'<?php // silence is golden', |
| 6891 |
); |
| 6892 |
|
| 6893 |
return in_array( $normalized, $known, true ); |
| 6894 |
} |
| 6895 |
|
| 6896 |
/** |
| 6897 |
* Folders of the installation that hold plugins or themes |
| 6898 |
* |
| 6899 |
* @return array Relative path => true, for the plugins folder and every |
| 6900 |
* theme root that lies inside the WordPress directory. |
| 6901 |
*/ |
| 6902 |
private function code_roots() { |
| 6903 |
global $wp_theme_directories; |
| 6904 |
|
| 6905 |
$roots = array(); |
| 6906 |
|
| 6907 |
foreach ( array_merge( array( WP_PLUGIN_DIR, get_theme_root() ), is_array( $wp_theme_directories ) ? $wp_theme_directories : array() ) as $root ) { |
| 6908 |
$root = untrailingslashit( str_replace( '\\', '/', (string) $root ) ); |
| 6909 |
|
| 6910 |
if ( '' !== $root && 0 === strpos( $root . '/', str_replace( '\\', '/', ABSPATH ) ) ) { |
| 6911 |
$roots[ $this->relative_path( $root ) ] = true; |
| 6912 |
} |
| 6913 |
} |
| 6914 |
|
| 6915 |
return $roots; |
| 6916 |
} |
| 6917 |
|
| 6918 |
/** |
| 6919 |
* What a partial scan offers to exclude, so that the next one reaches the rest |
| 6920 |
* |
| 6921 |
* The folders of the plugins and themes the scan did check, as lines for |
| 6922 |
* Excluded Paths, less the ones that are excluded by now. A scan starts |
| 6923 |
* from the beginning every time, so with these out of the way a scan |
| 6924 |
* run by hand spends its time on what the last one never got to. They |
| 6925 |
* have to come out of the list afterwards, and the tab says so for as |
| 6926 |
* long as they are there (see excluded_code_folders()). |
| 6927 |
* |
| 6928 |
* The list comes from stored results, and whoever reads it is told to |
| 6929 |
* paste it into the exclusions, so only what can be the folder of a |
| 6930 |
* plugin or of a theme is let through: one line with anything else in it |
| 6931 |
* would exclude far more than it says. |
| 6932 |
* |
| 6933 |
* @param mixed $results Scan results, as run_scan() returns them. |
| 6934 |
* @return array Relative paths, one per folder. |
| 6935 |
*/ |
| 6936 |
public function folders_to_exclude( $results ) { |
| 6937 |
$stored = is_array( $results ) && isset( $results['checked_folders'] ) && is_array( $results['checked_folders'] ) ? $results['checked_folders'] : array(); |
| 6938 |
$roots = $this->code_roots(); |
| 6939 |
$lines = array(); |
| 6940 |
|
| 6941 |
foreach ( $stored as $folder ) { |
| 6942 |
if ( ! is_string( $folder ) || 1 === preg_match( '/[\\x00-\\x1f\\x7f]/', $folder ) ) { |
| 6943 |
continue; |
| 6944 |
} |
| 6945 |
|
| 6946 |
$folder = trim( str_replace( '\\', '/', $folder ), '/' ); |
| 6947 |
$name = basename( $folder ); |
| 6948 |
|
| 6949 |
if ( '' === $name || '.' === $name || '..' === $name || ! isset( $roots[ dirname( $folder ) ] ) ) { |
| 6950 |
continue; |
| 6951 |
} |
| 6952 |
|
| 6953 |
if ( ! is_dir( ABSPATH . $folder ) || $this->is_path_excluded( ABSPATH . $folder . '/.' ) ) { |
| 6954 |
continue; |
| 6955 |
} |
| 6956 |
|
| 6957 |
$lines[ $folder ] = true; |
| 6958 |
} |
| 6959 |
|
| 6960 |
return array_keys( $lines ); |
| 6961 |
} |
| 6962 |
|
| 6963 |
/** |
| 6964 |
* Whole folders of code, or the uploads folder, that Excluded Paths keeps out of every scan |
| 6965 |
* |
| 6966 |
* A subfolder of uploads or of a plugin is an ordinary exclusion (a cache |
| 6967 |
* of compiled templates, say). A whole plugin, a whole theme, the |
| 6968 |
* must-use plugins or all of uploads is something else: the scan comes |
| 6969 |
* out complete and clean without having looked there. The tab and |
| 6970 |
* Security Check go by this list, so that nobody is left thinking those |
| 6971 |
* folders are being watched. |
| 6972 |
* |
| 6973 |
* @return array Relative paths. A folder that holds others (the plugins |
| 6974 |
* folder, a theme root) stands for all of them. |
| 6975 |
*/ |
| 6976 |
public function excluded_code_folders() { |
| 6977 |
if ( empty( $this->options['excluded_paths'] ) || ! is_array( $this->options['excluded_paths'] ) ) { |
| 6978 |
return array(); |
| 6979 |
} |
| 6980 |
|
| 6981 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 6982 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 6983 |
} |
| 6984 |
|
| 6985 |
$found = array(); |
| 6986 |
$roots = array_keys( $this->code_roots() ); |
| 6987 |
|
| 6988 |
foreach ( $roots as $root ) { |
| 6989 |
if ( $this->is_path_excluded( ABSPATH . $root . '/.' ) ) { |
| 6990 |
$found[ $root ] = true; |
| 6991 |
} |
| 6992 |
} |
| 6993 |
|
| 6994 |
$plugins_root = $this->relative_path( WP_PLUGIN_DIR ); |
| 6995 |
|
| 6996 |
if ( ! isset( $found[ $plugins_root ] ) ) { |
| 6997 |
foreach ( array_keys( get_plugins() ) as $plugin_file ) { |
| 6998 |
$slug = dirname( $plugin_file ); |
| 6999 |
|
| 7000 |
if ( '.' !== $slug && $this->is_path_excluded( WP_PLUGIN_DIR . '/' . $slug . '/.' ) ) { |
| 7001 |
$found[ $this->relative_path( WP_PLUGIN_DIR . '/' . $slug ) ] = true; |
| 7002 |
} |
| 7003 |
} |
| 7004 |
} |
| 7005 |
|
| 7006 |
foreach ( wp_get_themes() as $theme ) { |
| 7007 |
$dir = $theme->get_stylesheet_directory(); |
| 7008 |
|
| 7009 |
if ( ! isset( $found[ $this->relative_path( dirname( $dir ) ) ] ) && $this->is_path_excluded( $dir . '/.' ) ) { |
| 7010 |
$found[ $this->relative_path( $dir ) ] = true; |
| 7011 |
} |
| 7012 |
} |
| 7013 |
|
| 7014 |
if ( defined( 'WPMU_PLUGIN_DIR' ) && is_dir( WPMU_PLUGIN_DIR ) && $this->is_path_excluded( WPMU_PLUGIN_DIR . '/.' ) ) { |
| 7015 |
$found[ $this->relative_path( WPMU_PLUGIN_DIR ) ] = true; |
| 7016 |
} |
| 7017 |
|
| 7018 |
$uploads = wp_get_upload_dir(); |
| 7019 |
|
| 7020 |
if ( ! empty( $uploads['basedir'] ) && $this->is_path_excluded( $uploads['basedir'] . '/.' ) ) { |
| 7021 |
$found[ $this->relative_path( $uploads['basedir'] ) ] = true; |
| 7022 |
} |
| 7023 |
|
| 7024 |
return array_keys( $found ); |
| 7025 |
} |
| 7026 |
|
| 7027 |
/** |
| 7028 |
* Whether a scan checked less than it was asked to |
| 7029 |
* |
| 7030 |
* Either it ran out of time, or something could not be read or compared |
| 7031 |
* (a folder, a plugin whose copy on WordPress.org could not be fetched). |
| 7032 |
* The tab, the scan email and Security Check go by this, so none of them |
| 7033 |
* calls clean a result that covers only part of the site. |
| 7034 |
* |
| 7035 |
* @param mixed $results Scan results, as run_scan() returns them. |
| 7036 |
* @return bool |
| 7037 |
*/ |
| 7038 |
public static function is_partial( $results ) { |
| 7039 |
return is_array( $results ) && ( ! empty( $results['incomplete'] ) || ! empty( $results['errors'] ) ); |
| 7040 |
} |
| 7041 |
|
| 7042 |
/** |
| 7043 |
* What a scan did not get to, one line per thing |
| 7044 |
* |
| 7045 |
* The one source of that text, for the tab, the scan email and anything |
| 7046 |
* else that shows the result of a scan. In the order of the scan. |
| 7047 |
* |
| 7048 |
* @param mixed $results Scan results, as run_scan() returns them. |
| 7049 |
* @return array Lines ready to show, not escaped. Empty when nothing was |
| 7050 |
* left out. |
| 7051 |
*/ |
| 7052 |
public static function not_checked_lines( $results ) { |
| 7053 |
$left = is_array( $results ) && isset( $results['not_checked'] ) && is_array( $results['not_checked'] ) ? $results['not_checked'] : array(); |
| 7054 |
|
| 7055 |
$areas = isset( $left['areas'] ) && is_array( $left['areas'] ) ? $left['areas'] : array(); |
| 7056 |
$plugins = isset( $left['plugins'] ) && is_array( $left['plugins'] ) ? array_filter( array_map( 'strval', $left['plugins'] ), 'strlen' ) : array(); |
| 7057 |
$themes = isset( $left['themes'] ) && is_array( $left['themes'] ) ? array_filter( array_map( 'strval', $left['themes'] ), 'strlen' ) : array(); |
| 7058 |
$lines = array(); |
| 7059 |
|
| 7060 |
if ( in_array( 'uploads', $areas, true ) ) { |
| 7061 |
$lines[] = __( 'The uploads folder was not checked.', 'vigilante' ); |
| 7062 |
} |
| 7063 |
|
| 7064 |
if ( in_array( 'core', $areas, true ) ) { |
| 7065 |
$lines[] = __( 'The WordPress files were not checked completely.', 'vigilante' ); |
| 7066 |
} |
| 7067 |
|
| 7068 |
if ( in_array( 'config', $areas, true ) ) { |
| 7069 |
$lines[] = __( 'wp-config.php and .htaccess were not compared.', 'vigilante' ); |
| 7070 |
} |
| 7071 |
|
| 7072 |
if ( in_array( 'content', $areas, true ) ) { |
| 7073 |
$lines[] = __( 'The other folders of wp-content were not checked.', 'vigilante' ); |
| 7074 |
} |
| 7075 |
|
| 7076 |
if ( in_array( 'plugins', $areas, true ) ) { |
| 7077 |
$lines[] = __( 'No plugin was checked.', 'vigilante' ); |
| 7078 |
} elseif ( ! empty( $plugins ) ) { |
| 7079 |
/* translators: %s: Comma separated list of plugin names */ |
| 7080 |
$lines[] = sprintf( __( 'Plugins not checked: %s.', 'vigilante' ), implode( ', ', $plugins ) ); |
| 7081 |
} |
| 7082 |
|
| 7083 |
if ( in_array( 'unlisted', $areas, true ) ) { |
| 7084 |
$lines[] = __( 'Must-use plugins, drop-ins and loose PHP files were not checked.', 'vigilante' ); |
| 7085 |
} |
| 7086 |
|
| 7087 |
if ( in_array( 'themes', $areas, true ) ) { |
| 7088 |
$lines[] = __( 'No theme was checked.', 'vigilante' ); |
| 7089 |
} elseif ( ! empty( $themes ) ) { |
| 7090 |
/* translators: %s: Comma separated list of theme names */ |
| 7091 |
$lines[] = sprintf( __( 'Themes not checked: %s.', 'vigilante' ), implode( ', ', $themes ) ); |
| 7092 |
} |
| 7093 |
|
| 7094 |
return $lines; |
| 7095 |
} |
| 7096 |
|
| 7097 |
/** |
| 7098 |
* Whether the scheduled scan has not finished for longer than it should |
| 7099 |
* |
| 7100 |
* A scan that PHP or the server stops halfway stores nothing, so the date |
| 7101 |
* of the last one that finished is the only sign left. Twice the interval |
| 7102 |
* and one day more, because WP-Cron only runs when the site has a visit. |
| 7103 |
* |
| 7104 |
* @param int $last_scan Time of the last stored scan, 0 for none. |
| 7105 |
* @param string $frequency 'daily' or 'weekly'. |
| 7106 |
* @return bool |
| 7107 |
*/ |
| 7108 |
public static function is_scan_overdue( $last_scan, $frequency ) { |
| 7109 |
$last_scan = (int) $last_scan; |
| 7110 |
|
| 7111 |
if ( $last_scan <= 0 ) { |
| 7112 |
return false; |
| 7113 |
} |
| 7114 |
|
| 7115 |
$every = 'weekly' === $frequency ? WEEK_IN_SECONDS : DAY_IN_SECONDS; |
| 7116 |
|
| 7117 |
return ( time() - $last_scan ) > ( 2 * $every + DAY_IN_SECONDS ); |
| 7118 |
} |
| 7119 |
|
| 7120 |
/** |
| 7121 |
* What a kind of finding usually is, and what to do about it |
| 7122 |
* |
| 7123 |
* The one source of the text shown next to the findings of a scan, so the |
| 7124 |
* File Integrity tab and anything else that explains them say the same. |
| 7125 |
* Vigilant's own files have their own source, |
| 7126 |
* Vigilante_Self_Integrity_Guidance. |
| 7127 |
* |
| 7128 |
* @param string $type Finding type as stored in the scan results. |
| 7129 |
* @return array { |
| 7130 |
* @type string $label Name of the kind of finding, for a table cell. |
| 7131 |
* @type string $advice What it usually is and what to do. Empty for a |
| 7132 |
* type this method does not know. |
| 7133 |
* } |
| 7134 |
*/ |
| 7135 |
public static function finding_guidance( $type ) { |
| 7136 |
switch ( $type ) { |
| 7137 |
case 'php_in_uploads': |
| 7138 |
return array( |
| 7139 |
'label' => __( 'PHP in uploads', 'vigilante' ), |
| 7140 |
'advice' => __( 'Uploads is meant for media, not for code. It is normal only when a plugin you use writes PHP there, such as compiled templates or cache files: ignore the file, or add its folder to Excluded Paths if new ones keep appearing. If nothing you use explains it, treat it as malware.', 'vigilante' ), |
| 7141 |
); |
| 7142 |
|
| 7143 |
case 'double_extension': |
| 7144 |
return array( |
| 7145 |
'label' => __( 'Double extension', 'vigilante' ), |
| 7146 |
'advice' => __( 'A name such as photo.php.jpg is a common way to pass a script off as an image. Delete it unless you know who or what uploaded it.', 'vigilante' ), |
| 7147 |
); |
| 7148 |
|
| 7149 |
case 'htaccess_in_uploads': |
| 7150 |
return array( |
| 7151 |
'label' => __( '.htaccess in uploads', 'vigilante' ), |
| 7152 |
'advice' => __( 'A .htaccess here changes how the server treats this folder. One that only denies access is not listed. If it makes files run as code (AddHandler, SetHandler, php_value, Options ExecCGI) and you did not put it there, it is a sign of infection. If it only has rewrite rules, it usually belongs to an image or cache plugin, and it switches off for this folder the rule that blocks PHP in uploads.', 'vigilante' ), |
| 7153 |
); |
| 7154 |
|
| 7155 |
case 'php_in_root': |
| 7156 |
return array( |
| 7157 |
'label' => __( 'PHP in the site root', 'vigilante' ), |
| 7158 |
'advice' => __( 'A PHP file next to wp-config.php that WordPress does not ship. Hosting tools, firewalls and verification scripts put files here, and so do attackers. Ignore it if you know where it comes from; if you do not, look at what it contains before anything else.', 'vigilante' ), |
| 7159 |
); |
| 7160 |
|
| 7161 |
case 'extra_root': |
| 7162 |
return array( |
| 7163 |
'label' => __( 'Extra file in the site root', 'vigilante' ), |
| 7164 |
'advice' => __( 'WordPress does not ship this file. Verification files and the like are harmless. A copy of wp-config.php, a database dump or a backup archive here can be downloaded by anyone who guesses its name: move it out of the web root.', 'vigilante' ), |
| 7165 |
); |
| 7166 |
|
| 7167 |
case 'legacy_core': |
| 7168 |
return array( |
| 7169 |
'label' => __( 'Old core file', 'vigilante' ), |
| 7170 |
'advice' => __( 'Left behind by an earlier version of WordPress. Nothing uses it any more and it can be deleted.', 'vigilante' ), |
| 7171 |
); |
| 7172 |
|
| 7173 |
case 'suspicious_plugin': |
| 7174 |
case 'suspicious_theme': |
| 7175 |
return array( |
| 7176 |
'label' => 'suspicious_theme' === $type ? __( 'Hidden code in a theme', 'vigilante' ) : __( 'Hidden code in a plugin', 'vigilante' ), |
| 7177 |
'advice' => __( 'The file contains code written the way malware hides itself, and it is not part of the published copy, or there is no published copy to compare with. Some commercial plugins and themes obfuscate their own code and end up here. If you cannot tell, replace the plugin or theme with a clean copy from its author.', 'vigilante' ), |
| 7178 |
); |
| 7179 |
|
| 7180 |
case 'extra_plugin': |
| 7181 |
case 'extra_theme': |
| 7182 |
return array( |
| 7183 |
'label' => 'extra_theme' === $type ? __( 'Extra file in a theme', 'vigilante' ) : __( 'Extra file in a plugin', 'vigilante' ), |
| 7184 |
'advice' => __( 'A PHP file that is not in the copy WordPress.org publishes for the installed version. Some plugins and themes create files after they are installed, and a manual fix adds them too. If you cannot explain it, reinstall the plugin or theme: that removes everything that does not belong to it.', 'vigilante' ), |
| 7185 |
); |
| 7186 |
|
| 7187 |
case 'php_in_core': |
| 7188 |
return array( |
| 7189 |
'label' => __( 'PHP in a WordPress folder', 'vigilante' ), |
| 7190 |
'advice' => __( 'A PHP file in wp-admin or wp-includes that the installed version of WordPress does not ship. It is either left over from an older version or put there by someone: compare it with a clean copy of WordPress and delete it if it is not in it.', 'vigilante' ), |
| 7191 |
); |
| 7192 |
|
| 7193 |
case 'config_in_core': |
| 7194 |
return array( |
| 7195 |
'label' => __( 'Configuration file in a WordPress folder', 'vigilante' ), |
| 7196 |
'advice' => __( 'A .htaccess, .user.ini or php.ini in wp-admin or wp-includes. A .htaccess that asks for a password in wp-admin is a common protection, and some hosts leave a php.ini there. A line with auto_prepend_file or auto_append_file loads a file on every request: make sure you know which one.', 'vigilante' ), |
| 7197 |
); |
| 7198 |
|
| 7199 |
case 'php_config_in_uploads': |
| 7200 |
return array( |
| 7201 |
'label' => __( 'PHP configuration in uploads', 'vigilante' ), |
| 7202 |
'advice' => __( 'A .user.ini or php.ini here changes how PHP runs for this folder, and can make it load another file on every request. Nothing in uploads needs one: delete it unless you put it there.', 'vigilante' ), |
| 7203 |
); |
| 7204 |
|
| 7205 |
case 'script_in_uploads': |
| 7206 |
return array( |
| 7207 |
'label' => __( 'Script in uploads', 'vigilante' ), |
| 7208 |
'advice' => __( 'A script in another language (shell, Perl, Python, CGI). It only runs if the server is told to run it, which a .htaccess in the same folder can do. Delete it unless you know why it is there.', 'vigilante' ), |
| 7209 |
); |
| 7210 |
|
| 7211 |
case 'php_config_in_root': |
| 7212 |
return array( |
| 7213 |
'label' => __( 'PHP configuration in the site root', 'vigilante' ), |
| 7214 |
'advice' => __( 'This file makes PHP load another file before every request. Firewalls such as Wordfence set it up for themselves, and so do some hosts: check that the file it names is theirs. If nothing you installed needs it, that file runs before WordPress on every visit: treat it as a backdoor.', 'vigilante' ), |
| 7215 |
); |
| 7216 |
|
| 7217 |
case 'backup_in_root': |
| 7218 |
return array( |
| 7219 |
'label' => __( 'Backup in the site root', 'vigilante' ), |
| 7220 |
'advice' => __( 'A copy of wp-config.php, a database dump or an archive next to WordPress can be downloaded by anyone who guesses its name, and it holds passwords or the whole site. Move it out of the web root or delete it.', 'vigilante' ), |
| 7221 |
); |
| 7222 |
|
| 7223 |
case 'php_in_content': |
| 7224 |
return array( |
| 7225 |
'label' => __( 'Loose PHP in wp-content', 'vigilante' ), |
| 7226 |
'advice' => __( 'A PHP file, or a folder with PHP files, in wp-content or among the plugins or the themes, that WordPress does not list as a plugin, a theme or a drop-in. It does not show on the Plugins screen, and it can still be reached by its address. Some plugins keep a settings file there, WP Super Cache for one. If nothing you use explains it, look at what it contains.', 'vigilante' ), |
| 7227 |
); |
| 7228 |
|
| 7229 |
case 'suspicious_content': |
| 7230 |
return array( |
| 7231 |
'label' => __( 'Hidden code in wp-content', 'vigilante' ), |
| 7232 |
'advice' => __( 'The file contains code written the way malware hides itself, in a folder of wp-content that belongs to no plugin and no theme. Cache, backup and security plugins keep files in folders like these, and what they store can look like this. Find out which one the folder belongs to. If nothing you use explains the file, treat it as malware.', 'vigilante' ), |
| 7233 |
); |
| 7234 |
|
| 7235 |
case 'translation_with_code': |
| 7236 |
return array( |
| 7237 |
'label' => __( 'Translation file with code', 'vigilante' ), |
| 7238 |
'advice' => __( 'WordPress loads the translation files of the site language on every request, and a translation file holds text and nothing else. This one holds something more. Delete it and get the translations again from Dashboard > Updates. If it comes back, something on the site is writing it.', 'vigilante' ), |
| 7239 |
); |
| 7240 |
|
| 7241 |
case 'not_searched': |
| 7242 |
return array( |
| 7243 |
'label' => __( 'File not searched', 'vigilante' ), |
| 7244 |
'advice' => __( 'The search for hidden code could not look inside this file, nearly always because it is very large, and there is no official copy to compare it with. A PHP file of that size is unusual. Check that it belongs to the plugin, the theme or the folder it is in, and ignore it if it does.', 'vigilante' ), |
| 7245 |
); |
| 7246 |
|
| 7247 |
case 'server_config': |
| 7248 |
return array( |
| 7249 |
'label' => __( 'Server configuration file', 'vigilante' ), |
| 7250 |
'advice' => __( 'A .htaccess, .user.ini or php.ini outside uploads that makes the server run files as code or load a file on every request. Plugins rarely need one. If you cannot tell which one put it there, treat it as a sign of infection.', 'vigilante' ), |
| 7251 |
); |
| 7252 |
|
| 7253 |
case 'core': |
| 7254 |
return array( |
| 7255 |
'label' => __( 'WordPress core', 'vigilante' ), |
| 7256 |
'advice' => __( 'Does not match the copy WordPress.org publishes for your version and language. For a file in wp-admin, wp-includes or the site root, reinstall WordPress from Dashboard > Updates and scan again. A file of a bundled theme or of Akismet, under wp-content, appears here after that theme or plugin updates on its own, which is normal.', 'vigilante' ), |
| 7257 |
); |
| 7258 |
|
| 7259 |
case 'plugin': |
| 7260 |
case 'theme': |
| 7261 |
return array( |
| 7262 |
'label' => 'theme' === $type ? __( 'Theme', 'vigilante' ) : __( 'Plugin', 'vigilante' ), |
| 7263 |
'advice' => __( 'Does not match the copy WordPress.org publishes for the installed version. The usual causes are an update that did not finish, a release replaced on WordPress.org without a new version number, or a manual edit. Reinstall it; if the file changes again by itself, investigate.', 'vigilante' ), |
| 7264 |
); |
| 7265 |
|
| 7266 |
case 'critical_config': |
| 7267 |
return array( |
| 7268 |
'label' => __( 'Configuration file', 'vigilante' ), |
| 7269 |
'advice' => __( 'Changed since you last approved it. Cache, security and redirection plugins rewrite their own blocks in these files, often on every update, and that shows up here too. Review the changes and approve them if they belong to something you use. Lines you cannot explain, above all the ones that load a file or send requests to one, need a closer look.', 'vigilante' ), |
| 7270 |
); |
| 7271 |
} |
| 7272 |
|
| 7273 |
return array( |
| 7274 |
'label' => (string) $type, |
| 7275 |
'advice' => '', |
| 7276 |
); |
| 7277 |
} |
| 7278 |
|
| 7279 |
/** |
| 7280 |
* Check if a path is excluded from scanning |
| 7281 |
* |
| 7282 |
* @param string $path File path. |
| 7283 |
* @return bool |
| 7284 |
*/ |
| 7285 |
private function is_path_excluded( $path ) { |
| 7286 |
$excluded = $this->options['excluded_paths'] ?? array(); |
| 7287 |
|
| 7288 |
if ( empty( $excluded ) ) { |
| 7289 |
return false; |
| 7290 |
} |
| 7291 |
|
| 7292 |
$relative = $this->relative_path( $path ); |
| 7293 |
|
| 7294 |
foreach ( $excluded as $exclude ) { |
| 7295 |
$exclude = trim( trim( str_replace( '\\', '/', (string) $exclude ) ), '/' ); |
| 7296 |
|
| 7297 |
if ( '' === $exclude ) { |
| 7298 |
continue; |
| 7299 |
} |
| 7300 |
|
| 7301 |
/* |
| 7302 |
* Two forms, both with real boundaries. Until 2.9.9 this was a plain |
| 7303 |
* strpos() over the relative path, so an exclusion matched anywhere |
| 7304 |
* inside it: "cache" also silenced a plugin folder named mycache, |
| 7305 |
* "logs" silenced catalogs, and nothing told the user how much had |
| 7306 |
* stopped being watched. |
| 7307 |
* |
| 7308 |
* Anything with a slash is a path: it excludes exactly that file, or |
| 7309 |
* everything under it, anchored at the root of the installation. |
| 7310 |
*/ |
| 7311 |
if ( false !== strpos( $exclude, '/' ) ) { |
| 7312 |
if ( $relative === $exclude || 0 === strpos( $relative, $exclude . '/' ) ) { |
| 7313 |
return true; |
| 7314 |
} |
| 7315 |
|
| 7316 |
continue; |
| 7317 |
} |
| 7318 |
|
| 7319 |
/* |
| 7320 |
* A bare name excludes any folder called exactly that, at any depth, |
| 7321 |
* which is what someone typing "languages" means. It has to be the |
| 7322 |
* whole segment, not a fragment of one. |
| 7323 |
*/ |
| 7324 |
if ( $relative === $exclude |
| 7325 |
|| 0 === strpos( $relative, $exclude . '/' ) |
| 7326 |
|| false !== strpos( $relative, '/' . $exclude . '/' ) ) { |
| 7327 |
return true; |
| 7328 |
} |
| 7329 |
} |
| 7330 |
|
| 7331 |
return false; |
| 7332 |
} |
| 7333 |
|
| 7334 |
/** |
| 7335 |
* Path relative to the WordPress directory, with forward slashes |
| 7336 |
* |
| 7337 |
* @since 2.9.9 |
| 7338 |
* |
| 7339 |
* @param string $path Absolute path. |
| 7340 |
* @return string |
| 7341 |
*/ |
| 7342 |
private function relative_path( $path ) { |
| 7343 |
$path = str_replace( '\\', '/', (string) $path ); |
| 7344 |
$root = str_replace( '\\', '/', ABSPATH ); |
| 7345 |
|
| 7346 |
if ( 0 === strpos( $path, $root ) ) { |
| 7347 |
$path = substr( $path, strlen( $root ) ); |
| 7348 |
} |
| 7349 |
|
| 7350 |
return ltrim( $path, '/' ); |
| 7351 |
} |
| 7352 |
|
| 7353 |
/** |
| 7354 |
* Check if a file extension is excluded from scanning |
| 7355 |
* |
| 7356 |
* @param string $path File path. |
| 7357 |
* @return bool |
| 7358 |
*/ |
| 7359 |
private function is_extension_excluded( $path ) { |
| 7360 |
$excluded = $this->options['excluded_extensions'] ?? array(); |
| 7361 |
|
| 7362 |
if ( empty( $excluded ) ) { |
| 7363 |
return false; |
| 7364 |
} |
| 7365 |
|
| 7366 |
$extension = '.' . strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); |
| 7367 |
$relative = strtolower( $this->relative_path( $path ) ); |
| 7368 |
|
| 7369 |
foreach ( $excluded as $exclude ) { |
| 7370 |
$exclude = strtolower( trim( (string) $exclude ) ); |
| 7371 |
|
| 7372 |
if ( '' === $exclude ) { |
| 7373 |
continue; |
| 7374 |
} |
| 7375 |
|
| 7376 |
/* |
| 7377 |
* An extension on its own is global, as it has always been. Since |
| 7378 |
* 2.9.9 it can also be scoped to a folder, written as |
| 7379 |
* wp-content/languages/*.json, because the global form is a blunt |
| 7380 |
* instrument: excluding .json to quiet the translation files also |
| 7381 |
* stopped watching the 173 block.json files of core. |
| 7382 |
*/ |
| 7383 |
$scope = ''; |
| 7384 |
|
| 7385 |
if ( false !== strpos( $exclude, '*' ) ) { |
| 7386 |
$parts = explode( '*', $exclude, 2 ); |
| 7387 |
$scope = trim( $parts[0], '/' ); |
| 7388 |
$exclude = $parts[1]; |
| 7389 |
} |
| 7390 |
|
| 7391 |
if ( '' === $exclude ) { |
| 7392 |
continue; |
| 7393 |
} |
| 7394 |
|
| 7395 |
// Support both ".log" and "log" formats |
| 7396 |
if ( 0 !== strpos( $exclude, '.' ) ) { |
| 7397 |
$exclude = '.' . $exclude; |
| 7398 |
} |
| 7399 |
|
| 7400 |
if ( $exclude !== $extension ) { |
| 7401 |
continue; |
| 7402 |
} |
| 7403 |
|
| 7404 |
if ( '' === $scope ) { |
| 7405 |
return true; |
| 7406 |
} |
| 7407 |
|
| 7408 |
if ( $relative === $scope || 0 === strpos( $relative, $scope . '/' ) ) { |
| 7409 |
return true; |
| 7410 |
} |
| 7411 |
} |
| 7412 |
|
| 7413 |
return false; |
| 7414 |
} |
| 7415 |
|
| 7416 |
/** |
| 7417 |
* Filter out ignored files from results |
| 7418 |
* |
| 7419 |
* @param array $items Array of scan result items. |
| 7420 |
* @return array Filtered items. |
| 7421 |
*/ |
| 7422 |
private function filter_ignored( $items ) { |
| 7423 |
if ( empty( $this->ignored_files ) || empty( $items ) ) { |
| 7424 |
return $items; |
| 7425 |
} |
| 7426 |
|
| 7427 |
return array_values( |
| 7428 |
array_filter( |
| 7429 |
$items, |
| 7430 |
function ( $item ) { |
| 7431 |
/* |
| 7432 |
* On a network, wp-config.php and the root .htaccess are not a |
| 7433 |
* site's to silence: a change to them is closed by approving it, |
| 7434 |
* and approving takes a network administrator since 2.11.3. The |
| 7435 |
* ignore list is an option of each site, so until 2.11.8 the |
| 7436 |
* administrator of the main site without network rights hid a |
| 7437 |
* pending change from the network administrator's own screen by |
| 7438 |
* posting the file name to the ignore handler. |
| 7439 |
*/ |
| 7440 |
if ( is_multisite() && is_array( $item ) && 'critical_config' === ( $item['type'] ?? '' ) ) { |
| 7441 |
return true; |
| 7442 |
} |
| 7443 |
|
| 7444 |
// Findings about the manifest and the version of Vigilant |
| 7445 |
// itself are not about one file, so no entry of the list may |
| 7446 |
// hide them, on a single site either: ignoring the row of |
| 7447 |
// MANIFEST.sha256 took a replaced manifest out of the email. |
| 7448 |
// Vigilante_Self_Integrity::filter_ignored_findings() keeps |
| 7449 |
// them the same way. |
| 7450 |
if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) && in_array( $item['self_finding'] ?? '', array( 'manifest_replaced', 'manifest_unverified', 'manifest_missing', 'manifest_invalid', 'self_downgraded' ), true ) ) { |
| 7451 |
return true; |
| 7452 |
} |
| 7453 |
// Nor the findings of the walk of Vigilant's folder (a folder |
| 7454 |
// that cannot be listed, the folder that could not be walked): |
| 7455 |
// their path ends in a slash, and ignoring that row left the |
| 7456 |
// scan with no row and no email while the self-protection |
| 7457 |
// status stayed critical. |
| 7458 |
if ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) && '/' === substr( (string) ( $item['file'] ?? '' ), -1 ) ) { |
| 7459 |
return true; |
| 7460 |
} |
| 7461 |
|
| 7462 |
$file = is_array( $item ) && isset( $item['file'] ) ? $item['file'] : ''; |
| 7463 |
return ! in_array( $file, $this->ignored_files, true ); |
| 7464 |
} |
| 7465 |
) |
| 7466 |
); |
| 7467 |
} |
| 7468 |
|
| 7469 |
/** |
| 7470 |
* Send email notification based on notify_level setting |
| 7471 |
* |
| 7472 |
* Supports three levels: |
| 7473 |
* - 'all': notify on any issues (modified + suspicious + extra) |
| 7474 |
* - 'suspicious_only': notify only when suspicious or extra files found |
| 7475 |
* - 'disabled': never send |
| 7476 |
* |
| 7477 |
* Backward compatible with old notify_on_changes boolean. |
| 7478 |
* |
| 7479 |
* @param array $results Scan results. |
| 7480 |
*/ |
| 7481 |
private function maybe_send_notification( $results ) { |
| 7482 |
$options = is_array( $this->options ) ? $this->options : array(); |
| 7483 |
|
| 7484 |
// Closed/removed plugins (excluding ignored slugs) travel in this |
| 7485 |
// email, in the same tier as a suspicious file. |
| 7486 |
$closed_plugins = $this->collect_closed_plugins_for_email(); |
| 7487 |
|
| 7488 |
/* |
| 7489 |
* Self-protection is not part of this decision. Its alert is its own |
| 7490 |
* and no setting switches it off, so this email is about the files of |
| 7491 |
* the site: core, plugins, themes, uploads and the two shared |
| 7492 |
* configuration files. Missing files do not send it on their own: it |
| 7493 |
* has no section to list them in. |
| 7494 |
*/ |
| 7495 |
|
| 7496 |
// Instant alert: every finding travels, modified files included, |
| 7497 |
// whatever the level below says. A scan with nothing to report goes |
| 7498 |
// through as well, so that what was reported is forgotten. |
| 7499 |
if ( ! empty( $options['instant_alert'] ) ) { |
| 7500 |
$this->send_new_findings( $results, 'all', $closed_plugins ); |
| 7501 |
return; |
| 7502 |
} |
| 7503 |
|
| 7504 |
// Determine notify level with backward compatibility |
| 7505 |
$notify_level = $options['notify_level'] ?? ''; |
| 7506 |
|
| 7507 |
// Backward compat: if notify_level not set, check old boolean |
| 7508 |
if ( empty( $notify_level ) ) { |
| 7509 |
if ( ! empty( $options['notify_on_changes'] ) ) { |
| 7510 |
$notify_level = 'all'; |
| 7511 |
} else { |
| 7512 |
$notify_level = 'disabled'; |
| 7513 |
} |
| 7514 |
} |
| 7515 |
|
| 7516 |
if ( 'disabled' === $notify_level ) { |
| 7517 |
// Nothing is sent, and nothing stays on record as reported: |
| 7518 |
// whoever switches the email back on is told about everything |
| 7519 |
// that is pending then. |
| 7520 |
delete_option( self::NOTIFIED_OPTION ); |
| 7521 |
return; |
| 7522 |
} |
| 7523 |
|
| 7524 |
// 'suspicious_only' treats suspicious/extra, critical_config AND closed |
| 7525 |
// plugins as serious. A scan with nothing to send at this level still |
| 7526 |
// goes through send_new_findings(), which then forgets what it had |
| 7527 |
// reported, so a finding that goes away and comes back is new again. |
| 7528 |
$this->send_new_findings( $results, $notify_level, $closed_plugins ); |
| 7529 |
} |
| 7530 |
|
| 7531 |
/** |
| 7532 |
* What the scan email has already reported to the recipients |
| 7533 |
*/ |
| 7534 |
const NOTIFIED_OPTION = 'vigilante_integrity_notified'; |
| 7535 |
|
| 7536 |
/** |
| 7537 |
* Send the scan email only when the scan found something new |
| 7538 |
* |
| 7539 |
* Until now the email went out after every scan for as long as a finding |
| 7540 |
* was still listed, so a plugin that rewrote its block in .htaccess meant |
| 7541 |
* the same alert every day until somebody approved the change. Now every |
| 7542 |
* finding is reported once: the email goes out when a scan finds something |
| 7543 |
* the recipients have not been told about, and it lists everything that is |
| 7544 |
* pending, old and new. |
| 7545 |
* |
| 7546 |
* A finding is the file, its kind and its content (by its hash), so a |
| 7547 |
* file that changes again under the same name is new, also when the |
| 7548 |
* change keeps its size and its date. What goes away is forgotten, so it |
| 7549 |
* is new again if it comes back. Nothing is recorded when the email could |
| 7550 |
* not be sent, and the next scan tries again. |
| 7551 |
* |
| 7552 |
* @param array $results Scan results. |
| 7553 |
* @param string $notify_level 'all' or 'suspicious_only'. |
| 7554 |
* @param array $closed_plugins Closed plugins, keyed by slug. |
| 7555 |
*/ |
| 7556 |
private function send_new_findings( $results, $notify_level, $closed_plugins ) { |
| 7557 |
$keys = $this->notification_keys( $results, $closed_plugins, 'all' === $notify_level ); |
| 7558 |
$told = get_option( self::NOTIFIED_OPTION, array() ); |
| 7559 |
$told = is_array( $told ) ? $told : array(); |
| 7560 |
|
| 7561 |
// A partial scan cannot tell whether what it did not reach is still |
| 7562 |
// there, so it forgets nothing: with the findings of an unchecked |
| 7563 |
// plugin forgotten, the next scan that reached it would report them |
| 7564 |
// again as new, and on a site whose scans are always partial that |
| 7565 |
// is the same email over and over. |
| 7566 |
$known = self::is_partial( $results ) ? array_values( array_unique( array_merge( $keys, $told ) ) ) : $keys; |
| 7567 |
|
| 7568 |
// The record is bounded, and the comparison is not: what does not fit |
| 7569 |
// in the record is reported again, never left out. The keys come in |
| 7570 |
// order of importance, so what does not fit is the least of it. |
| 7571 |
$record = array_slice( $known, 0, 5000 ); |
| 7572 |
|
| 7573 |
if ( empty( array_diff( $keys, $told ) ) ) { |
| 7574 |
if ( count( $record ) !== count( $told ) ) { |
| 7575 |
update_option( self::NOTIFIED_OPTION, $record, false ); |
| 7576 |
} |
| 7577 |
return; |
| 7578 |
} |
| 7579 |
|
| 7580 |
if ( $this->send_notification( $results, $notify_level, $closed_plugins ) ) { |
| 7581 |
update_option( self::NOTIFIED_OPTION, $record, false ); |
| 7582 |
} |
| 7583 |
} |
| 7584 |
|
| 7585 |
/** |
| 7586 |
* One key per finding the scan email would carry at this level |
| 7587 |
* |
| 7588 |
* @param array $results Scan results. |
| 7589 |
* @param array $closed_plugins Closed plugins, keyed by slug. |
| 7590 |
* @param bool $with_modified Whether files that are only modified travel |
| 7591 |
* in the email at this level. |
| 7592 |
* @return array |
| 7593 |
*/ |
| 7594 |
private function notification_keys( $results, $closed_plugins, $with_modified ) { |
| 7595 |
$keys = array( |
| 7596 |
'config' => array(), |
| 7597 |
'closed' => array(), |
| 7598 |
'suspicious' => array(), |
| 7599 |
'modified' => array(), |
| 7600 |
'extra' => array(), |
| 7601 |
); |
| 7602 |
|
| 7603 |
foreach ( (array) ( $results['modified'] ?? array() ) as $item ) { |
| 7604 |
if ( ! is_array( $item ) ) { |
| 7605 |
continue; |
| 7606 |
} |
| 7607 |
|
| 7608 |
$type = (string) ( $item['type'] ?? '' ); |
| 7609 |
$config = 'critical_config' === $type; |
| 7610 |
|
| 7611 |
// Vigilant's own files have their own alert. |
| 7612 |
if ( 'vigilante_self' === $type || ( ! $config && ! $with_modified ) ) { |
| 7613 |
continue; |
| 7614 |
} |
| 7615 |
|
| 7616 |
$keys[ $config ? 'config' : 'modified' ][] = md5( 'modified|' . $type . '|' . (string) ( $item['file'] ?? '' ) . '|' . (string) ( $item['actual_hash'] ?? '' ) ); |
| 7617 |
} |
| 7618 |
|
| 7619 |
foreach ( (array) $closed_plugins as $slug => $info ) { |
| 7620 |
$keys['closed'][] = md5( 'closed|' . $slug . '|' . ( is_array( $info ) ? (string) ( $info['state'] ?? '' ) : '' ) ); |
| 7621 |
} |
| 7622 |
|
| 7623 |
foreach ( array( 'suspicious', 'extra' ) as $bucket ) { |
| 7624 |
foreach ( (array) ( $results[ $bucket ] ?? array() ) as $item ) { |
| 7625 |
$type = is_array( $item ) ? (string) ( $item['type'] ?? '' ) : ''; |
| 7626 |
|
| 7627 |
if ( 'vigilante_self' === $type ) { |
| 7628 |
continue; |
| 7629 |
} |
| 7630 |
|
| 7631 |
$file = is_array( $item ) ? (string) ( $item['file'] ?? '' ) : (string) $item; |
| 7632 |
$state = ''; |
| 7633 |
$path = ABSPATH . ltrim( $file, '/' ); |
| 7634 |
|
| 7635 |
// By content, so a file rewritten with the same size and the |
| 7636 |
// same date is still a different file. The ones too large to |
| 7637 |
// hash on every scan go by size, date and the time of their |
| 7638 |
// last change of any kind, which writing to a file moves and |
| 7639 |
// which cannot be set back the way the date can. |
| 7640 |
if ( '' !== $file && is_file( $path ) ) { |
| 7641 |
$state = filesize( $path ) <= MB_IN_BYTES ? (string) md5_file( $path ) : filesize( $path ) . ':' . filemtime( $path ) . ':' . filectime( $path ); |
| 7642 |
} |
| 7643 |
|
| 7644 |
$keys[ $bucket ][] = md5( $bucket . '|' . $type . '|' . $file . '|' . $state ); |
| 7645 |
} |
| 7646 |
} |
| 7647 |
|
| 7648 |
// In order of importance: only the first 5,000 are remembered, and |
| 7649 |
// what is not remembered is reported again. |
| 7650 |
return array_values( array_unique( array_merge( $keys['config'], $keys['closed'], $keys['suspicious'], $keys['modified'], $keys['extra'] ) ) ); |
| 7651 |
} |
| 7652 |
|
| 7653 |
/** |
| 7654 |
* Collect the closed/removed plugins (excluding ignored slugs) so they can |
| 7655 |
* be folded into the scan email digest. Returns an array keyed by slug. |
| 7656 |
* |
| 7657 |
* @return array |
| 7658 |
*/ |
| 7659 |
private function collect_closed_plugins_for_email() { |
| 7660 |
if ( empty( $this->options['check_closed_plugins'] ) ) { |
| 7661 |
return array(); |
| 7662 |
} |
| 7663 |
if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) { |
| 7664 |
require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php'; |
| 7665 |
} |
| 7666 |
$checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log ); |
| 7667 |
return $checker->get_closed_plugins(); |
| 7668 |
} |
| 7669 |
|
| 7670 |
/** |
| 7671 |
* Merge scan results |
| 7672 |
* |
| 7673 |
* @param array $results1 First results. |
| 7674 |
* @param array $results2 Second results. |
| 7675 |
* @return array Merged results. |
| 7676 |
*/ |
| 7677 |
private function merge_results( $results1, $results2 ) { |
| 7678 |
return array( |
| 7679 |
'scanned' => $results1['scanned'] + ( $results2['scanned'] ?? 0 ), |
| 7680 |
'ok' => $results1['ok'] + ( $results2['ok'] ?? 0 ), |
| 7681 |
'modified' => array_merge( $results1['modified'], $results2['modified'] ?? array() ), |
| 7682 |
'missing' => array_merge( $results1['missing'] ?? array(), $results2['missing'] ?? array() ), |
| 7683 |
'suspicious' => array_merge( $results1['suspicious'] ?? array(), $results2['suspicious'] ?? array() ), |
| 7684 |
'extra' => array_merge( $results1['extra'] ?? array(), $results2['extra'] ?? array() ), |
| 7685 |
'new' => $results1['new'] ?? array(), |
| 7686 |
'errors' => array_merge( $results1['errors'] ?? array(), $results2['errors'] ?? array() ), |
| 7687 |
// Plugins and themes with no official copy to compare with. |
| 7688 |
'unverified' => array_merge( $results1['unverified'] ?? array(), $results2['unverified'] ?? array() ), |
| 7689 |
'scan_time' => $results1['scan_time'] ?? 0, |
| 7690 |
'incomplete' => $results1['incomplete'] ?? false, |
| 7691 |
); |
| 7692 |
} |
| 7693 |
|
| 7694 |
/** |
| 7695 |
* Send notification email about scan results |
| 7696 |
* |
| 7697 |
* @param array $results Scan results. |
| 7698 |
* @param string $notify_level Notification level ('all' or 'suspicious_only'). |
| 7699 |
* @param array $closed_plugins Optional map of slug=>state-entry for closed/removed |
| 7700 |
* plugins to include as a dedicated section. |
| 7701 |
*/ |
| 7702 |
private function send_notification( $results, $notify_level = 'all', $closed_plugins = array() ) { |
| 7703 |
$to = Vigilante_Email_Template::get_admin_recipients(); |
| 7704 |
$site_name = get_bloginfo( 'name' ); |
| 7705 |
|
| 7706 |
// Split critical_config files from regular modified so they get their own |
| 7707 |
// prominent section in the email, next to suspicious/extra. |
| 7708 |
$critical_config = array(); |
| 7709 |
$regular_modified = array(); |
| 7710 |
foreach ( $results['modified'] ?? array() as $item ) { |
| 7711 |
if ( is_array( $item ) && isset( $item['type'] ) && 'critical_config' === $item['type'] ) { |
| 7712 |
// Synthesize a reason string with size + line-diff info so get_section_html shows it |
| 7713 |
$baseline_size = $item['baseline_size'] ?? 0; |
| 7714 |
$current_size = $item['current_size'] ?? 0; |
| 7715 |
$added_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['added'] ?? array() ) : 0; |
| 7716 |
$removed_count = is_array( $item['diff'] ?? null ) ? count( $item['diff']['removed'] ?? array() ) : 0; |
| 7717 |
$diff_unavail = is_array( $item['diff'] ?? null ) && ! empty( $item['diff']['unavailable'] ); |
| 7718 |
|
| 7719 |
$reason = sprintf( |
| 7720 |
/* translators: 1: baseline size, 2: current size */ |
| 7721 |
__( '%1$s → %2$s bytes', 'vigilante' ), |
| 7722 |
number_format_i18n( $baseline_size ), |
| 7723 |
number_format_i18n( $current_size ) |
| 7724 |
); |
| 7725 |
if ( ! $diff_unavail ) { |
| 7726 |
$reason .= sprintf( ' (+%d / -%d %s)', $added_count, $removed_count, __( 'lines', 'vigilante' ) ); |
| 7727 |
} |
| 7728 |
|
| 7729 |
$item['reason'] = $reason; |
| 7730 |
$critical_config[] = $item; |
| 7731 |
} else { |
| 7732 |
$regular_modified[] = $item; |
| 7733 |
} |
| 7734 |
} |
| 7735 |
|
| 7736 |
/* |
| 7737 |
* Self-protection does not travel in this email any more: it has its own |
| 7738 |
* alert, which no setting switches off (Vigilante_Self_Integrity:: |
| 7739 |
* maybe_send_self_alert()). Older stored results can still carry its |
| 7740 |
* rows, so they are dropped here instead of being listed as ordinary |
| 7741 |
* files. |
| 7742 |
*/ |
| 7743 |
foreach ( array( 'suspicious', 'extra', 'missing' ) as $self_bucket ) { |
| 7744 |
if ( empty( $results[ $self_bucket ] ) || ! is_array( $results[ $self_bucket ] ) ) { |
| 7745 |
continue; |
| 7746 |
} |
| 7747 |
$results[ $self_bucket ] = array_values( |
| 7748 |
array_filter( |
| 7749 |
$results[ $self_bucket ], |
| 7750 |
function ( $item ) { |
| 7751 |
return ! ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) ); |
| 7752 |
} |
| 7753 |
) |
| 7754 |
); |
| 7755 |
} |
| 7756 |
$regular_modified = array_values( |
| 7757 |
array_filter( |
| 7758 |
$regular_modified, |
| 7759 |
function ( $item ) { |
| 7760 |
return ! ( is_array( $item ) && 'vigilante_self' === ( $item['type'] ?? '' ) ); |
| 7761 |
} |
| 7762 |
) |
| 7763 |
); |
| 7764 |
|
| 7765 |
$suspicious_count = count( $results['suspicious'] ?? array() ); |
| 7766 |
$extra_count = count( $results['extra'] ?? array() ); |
| 7767 |
$critical_config_count = count( $critical_config ); |
| 7768 |
$modified_count = count( $regular_modified ); |
| 7769 |
$closed_count = count( $closed_plugins ); |
| 7770 |
|
| 7771 |
// The subject says what was found. Suspicious files and closed plugins |
| 7772 |
// keep the alert wording. A change in wp-config.php or .htaccess with |
| 7773 |
// nothing else next to it names the files instead: most of the time it |
| 7774 |
// is a plugin rewriting its own block, and "SECURITY ALERT" on every |
| 7775 |
// one of those taught people to read the subject as "I was hacked". |
| 7776 |
if ( $suspicious_count > 0 || $closed_count > 0 ) { |
| 7777 |
$subject = sprintf( |
| 7778 |
/* translators: %s: Site name */ |
| 7779 |
__( '[%s] SECURITY ALERT: File integrity issues detected', 'vigilante' ), |
| 7780 |
$site_name |
| 7781 |
); |
| 7782 |
} elseif ( $critical_config_count > 0 ) { |
| 7783 |
$changed = array(); |
| 7784 |
foreach ( $critical_config as $item ) { |
| 7785 |
$changed[] = (string) ( $item['file'] ?? '' ); |
| 7786 |
} |
| 7787 |
|
| 7788 |
$subject = sprintf( |
| 7789 |
/* translators: 1: Site name, 2: The file that changed, or both of them, such as "wp-config.php, .htaccess" */ |
| 7790 |
__( '[%1$s] %2$s changed: review needed', 'vigilante' ), |
| 7791 |
$site_name, |
| 7792 |
implode( ', ', array_unique( array_filter( $changed ) ) ) |
| 7793 |
); |
| 7794 |
} else { |
| 7795 |
$subject = sprintf( |
| 7796 |
/* translators: %s: Site name */ |
| 7797 |
__( '[%s] File integrity issues detected', 'vigilante' ), |
| 7798 |
$site_name |
| 7799 |
); |
| 7800 |
} |
| 7801 |
|
| 7802 |
// Build HTML email using template wrapper |
| 7803 |
$inner = ''; |
| 7804 |
|
| 7805 |
// Summary counts |
| 7806 |
$inner .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom:20px;">'; |
| 7807 |
$inner .= '<tr>'; |
| 7808 |
if ( $suspicious_count > 0 ) { |
| 7809 |
$inner .= $this->get_stat_cell( $suspicious_count, __( 'Suspicious', 'vigilante' ), '#d63638' ); |
| 7810 |
} |
| 7811 |
if ( $extra_count > 0 ) { |
| 7812 |
$inner .= $this->get_stat_cell( $extra_count, __( 'Extra', 'vigilante' ), '#b32d2e' ); |
| 7813 |
} |
| 7814 |
if ( $critical_config_count > 0 ) { |
| 7815 |
$inner .= $this->get_stat_cell( $critical_config_count, __( 'Critical', 'vigilante' ), '#e36210' ); |
| 7816 |
} |
| 7817 |
if ( $closed_count > 0 ) { |
| 7818 |
$inner .= $this->get_stat_cell( $closed_count, __( 'Closed', 'vigilante' ), '#d63638' ); |
| 7819 |
} |
| 7820 |
if ( 'all' === $notify_level && $modified_count > 0 ) { |
| 7821 |
$inner .= $this->get_stat_cell( $modified_count, __( 'Modified', 'vigilante' ), '#dba617' ); |
| 7822 |
} |
| 7823 |
$inner .= $this->get_stat_cell( $results['scanned'] ?? 0, __( 'Scanned', 'vigilante' ), '#50575e' ); |
| 7824 |
$inner .= '</tr></table>'; |
| 7825 |
|
| 7826 |
// Suspicious files section |
| 7827 |
if ( ! empty( $results['suspicious'] ) ) { |
| 7828 |
$inner .= $this->get_section_html( |
| 7829 |
__( 'Suspicious files', 'vigilante' ), |
| 7830 |
__( 'Files that can run code where none is expected, or that contain code written the way malware hides itself. Some are harmless, such as cache files a plugin generates or a .htaccess that protects a folder: check the path and the reason before acting.', 'vigilante' ), |
| 7831 |
$results['suspicious'], |
| 7832 |
'#d63638', |
| 7833 |
'#fef1f1', |
| 7834 |
20, |
| 7835 |
true |
| 7836 |
); |
| 7837 |
} |
| 7838 |
|
| 7839 |
// Extra files section |
| 7840 |
if ( ! empty( $results['extra'] ) ) { |
| 7841 |
$inner .= $this->get_section_html( |
| 7842 |
__( 'Extra files', 'vigilante' ), |
| 7843 |
__( 'Files that are not part of WordPress, or of the plugin or theme they sit in. Many are legitimate. A copy of wp-config.php or a database dump in the site root should be moved out of it.', 'vigilante' ), |
| 7844 |
$results['extra'], |
| 7845 |
'#b32d2e', |
| 7846 |
'#fdf6f4', |
| 7847 |
20, |
| 7848 |
true |
| 7849 |
); |
| 7850 |
} |
| 7851 |
|
| 7852 |
// Critical config files section (wp-config.php, .htaccess modified outside Vigilante) |
| 7853 |
if ( ! empty( $critical_config ) ) { |
| 7854 |
$inner .= $this->get_section_html( |
| 7855 |
__( 'Critical config files modified', 'vigilante' ), |
| 7856 |
__( 'Changed since you last approved them. Cache, security and redirection plugins rewrite their own blocks in these files, often on every update, and attackers write to them too. Review the changes and approve them if they belong to something you use.', 'vigilante' ), |
| 7857 |
$critical_config, |
| 7858 |
'#e36210', |
| 7859 |
'#fdf2e6', |
| 7860 |
10, |
| 7861 |
true |
| 7862 |
); |
| 7863 |
} |
| 7864 |
|
| 7865 |
// Modified files section (only if notify_level is 'all') |
| 7866 |
if ( 'all' === $notify_level && ! empty( $regular_modified ) ) { |
| 7867 |
$inner .= $this->get_section_html( |
| 7868 |
__( 'Modified files', 'vigilante' ), |
| 7869 |
__( 'These files do not match the copy WordPress.org publishes for the installed version. Reinstalling the plugin, the theme or WordPress brings the original back.', 'vigilante' ), |
| 7870 |
$regular_modified, |
| 7871 |
'#dba617', |
| 7872 |
'#fdf8e8', |
| 7873 |
15, |
| 7874 |
false |
| 7875 |
); |
| 7876 |
} |
| 7877 |
|
| 7878 |
// Closed + Removed plugins section. |
| 7879 |
// Same tier as suspicious files: WordPress.org has flagged the plugin as |
| 7880 |
// closed or removed, the site keeps running its code, and ignoring the |
| 7881 |
// finding is an explicit per-slug action by the admin. |
| 7882 |
if ( $closed_count > 0 ) { |
| 7883 |
$inner .= $this->build_closed_plugins_email_section( $closed_plugins ); |
| 7884 |
} |
| 7885 |
|
| 7886 |
// A partial scan says so and names what it did not get to. With no |
| 7887 |
// finding in a plugin that was never looked at, silence here read as |
| 7888 |
// "that plugin is fine". |
| 7889 |
if ( self::is_partial( $results ) ) { |
| 7890 |
$partial_lines = array_merge( |
| 7891 |
array_slice( array_map( 'strval', (array) ( $results['errors'] ?? array() ) ), 0, 10 ), |
| 7892 |
self::not_checked_lines( $results ) |
| 7893 |
); |
| 7894 |
|
| 7895 |
$inner .= '<p style="margin:0 0 8px;font-size:13px;color:#1d2327;"><strong>' . esc_html__( 'The last scan could not check everything, so its result is partial.', 'vigilante' ) . '</strong></p>'; |
| 7896 |
$inner .= '<ul style="margin:0 0 16px 18px;padding:0;font-size:13px;color:#50575e;">'; |
| 7897 |
|
| 7898 |
foreach ( $partial_lines as $partial_line ) { |
| 7899 |
$inner .= '<li>' . esc_html( $partial_line ) . '</li>'; |
| 7900 |
} |
| 7901 |
|
| 7902 |
$inner .= '</ul>'; |
| 7903 |
} |
| 7904 |
|
| 7905 |
// Where the explanation is. The email stays a list; what each kind of |
| 7906 |
// finding usually is and what to do about it lives next to the results. |
| 7907 |
$inner .= '<p style="margin:0 0 16px;font-size:12px;color:#50575e;">' . esc_html__( 'File Integrity explains what each kind of finding usually is and what to do about it. A finding stays listed until you approve it, ignore it or fix it.', 'vigilante' ) . '</p>'; |
| 7908 |
|
| 7909 |
// CTA button |
| 7910 |
$inner .= Vigilante_Email_Template::button( |
| 7911 |
admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-last-scan' ), |
| 7912 |
__( 'Review in Vigilant', 'vigilante' ) |
| 7913 |
); |
| 7914 |
|
| 7915 |
$is_alert = ( $suspicious_count > 0 || $critical_config_count > 0 || $closed_count > 0 ); |
| 7916 |
|
| 7917 |
// Same three cases as the subject. |
| 7918 |
if ( $suspicious_count > 0 || $closed_count > 0 ) { |
| 7919 |
$title = __( 'Security alert', 'vigilante' ); |
| 7920 |
} elseif ( $critical_config_count > 0 ) { |
| 7921 |
$title = __( 'Configuration file changed', 'vigilante' ); |
| 7922 |
} else { |
| 7923 |
$title = __( 'File integrity report', 'vigilante' ); |
| 7924 |
} |
| 7925 |
|
| 7926 |
// The caller records what was reported only when the email went out. |
| 7927 |
return (bool) Vigilante_Email_Template::send( $to, $subject, $title, $inner, $is_alert ); |
| 7928 |
} |
| 7929 |
|
| 7930 |
/** |
| 7931 |
* Build the closed + removed plugins block for the scan email. |
| 7932 |
* |
| 7933 |
* Reuses the same visual treatment as the suspicious files section |
| 7934 |
* (red accent, danger description) because the security tier is the |
| 7935 |
* same: WordPress.org has marked the plugin as compromised or removed. |
| 7936 |
* |
| 7937 |
* @param array $closed_plugins Map of slug=>state entry. |
| 7938 |
* @return string HTML block. |
| 7939 |
*/ |
| 7940 |
private function build_closed_plugins_email_section( $closed_plugins ) { |
| 7941 |
$color = '#d63638'; |
| 7942 |
$bg_color = '#fef1f1'; |
| 7943 |
$title = __( 'Closed + Removed plugins', 'vigilante' ); |
| 7944 |
$desc = __( 'These plugins have been closed in the WordPress.org repository. Closures usually indicate malware, security issues, guideline violations, or supply chain attacks. Uninstall and replace as soon as possible.', 'vigilante' ); |
| 7945 |
|
| 7946 |
$html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">'; |
| 7947 |
$html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>'; |
| 7948 |
$html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $desc ) . '</p>'; |
| 7949 |
|
| 7950 |
$html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">'; |
| 7951 |
foreach ( $closed_plugins as $slug => $entry ) { |
| 7952 |
$name = isset( $entry['name'] ) ? $entry['name'] : $slug; |
| 7953 |
$version = isset( $entry['version'] ) ? $entry['version'] : ''; |
| 7954 |
$state = isset( $entry['state'] ) ? $entry['state'] : ''; |
| 7955 |
$state_label = 'closed' === $state ? __( 'Closed', 'vigilante' ) : __( 'Removed', 'vigilante' ); |
| 7956 |
$closed_date = isset( $entry['closed_date'] ) ? $entry['closed_date'] : ''; |
| 7957 |
$reason = isset( $entry['closed_reason_text'] ) && '' !== $entry['closed_reason_text'] |
| 7958 |
? $entry['closed_reason_text'] |
| 7959 |
: ''; |
| 7960 |
|
| 7961 |
$detail_bits = array(); |
| 7962 |
$detail_bits[] = $state_label; |
| 7963 |
if ( '' !== $closed_date ) { |
| 7964 |
$detail_bits[] = esc_html( $closed_date ); |
| 7965 |
} |
| 7966 |
if ( '' !== $version ) { |
| 7967 |
$detail_bits[] = 'v' . esc_html( $version ); |
| 7968 |
} |
| 7969 |
|
| 7970 |
$html .= '<tr>'; |
| 7971 |
$html .= '<td style="padding:4px 0;color:#1d2327;font-family:Consolas,Monaco,monospace;font-size:11px;word-break:break-all;">'; |
| 7972 |
$html .= '<strong>' . esc_html( $name ) . '</strong> · <a href="' . esc_url( 'https://wordpress.org/plugins/' . $slug . '/' ) . '" style="color:#2271b1;text-decoration:none;"><code>' . esc_html( $slug ) . '</code></a>'; |
| 7973 |
$html .= '</td></tr>'; |
| 7974 |
$html .= '<tr><td style="padding:0 0 4px 12px;color:#787c82;font-size:11px;">' . esc_html( implode( ' · ', array_map( 'wp_strip_all_tags', $detail_bits ) ) ) . '</td></tr>'; |
| 7975 |
if ( '' !== $reason ) { |
| 7976 |
$html .= '<tr><td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td></tr>'; |
| 7977 |
} |
| 7978 |
} |
| 7979 |
$html .= '</table>'; |
| 7980 |
$html .= '</div>'; |
| 7981 |
|
| 7982 |
return $html; |
| 7983 |
} |
| 7984 |
|
| 7985 |
/** |
| 7986 |
* Get a summary stat cell for email |
| 7987 |
* |
| 7988 |
* @param int $count Stat count. |
| 7989 |
* @param string $label Stat label. |
| 7990 |
* @param string $color Color hex. |
| 7991 |
* @return string HTML table cell. |
| 7992 |
*/ |
| 7993 |
private function get_stat_cell( $count, $label, $color ) { |
| 7994 |
$html = '<td style="text-align:center;padding:12px 8px;">'; |
| 7995 |
$html .= '<div style="font-size:24px;font-weight:700;color:' . $color . ';line-height:1.2;">' . (int) $count . '</div>'; |
| 7996 |
$html .= '<div style="font-size:11px;color:#50575e;text-transform:uppercase;letter-spacing:0.5px;">' . esc_html( $label ) . '</div>'; |
| 7997 |
$html .= '</td>'; |
| 7998 |
|
| 7999 |
return $html; |
| 8000 |
} |
| 8001 |
|
| 8002 |
/** |
| 8003 |
* Get an HTML section for file list in email |
| 8004 |
* |
| 8005 |
* @param string $title Section title. |
| 8006 |
* @param string $description Section description. |
| 8007 |
* @param array $files Array of file items. |
| 8008 |
* @param string $color Accent color. |
| 8009 |
* @param string $bg_color Background color. |
| 8010 |
* @param int $max Max files to show. |
| 8011 |
* @param bool $show_reason Whether to show reason column. |
| 8012 |
* @return string HTML. |
| 8013 |
*/ |
| 8014 |
private function get_section_html( $title, $description, $files, $color, $bg_color, $max, $show_reason ) { |
| 8015 |
$total = count( $files ); |
| 8016 |
$shown = array_slice( $files, 0, $max ); |
| 8017 |
|
| 8018 |
$html = '<div style="background:' . $bg_color . ';border-left:4px solid ' . $color . ';border-radius:4px;padding:14px 16px;margin-bottom:16px;">'; |
| 8019 |
$html .= '<h2 style="margin:0 0 4px;font-size:14px;color:' . $color . ';">' . esc_html( $title ) . '</h2>'; |
| 8020 |
$html .= '<p style="margin:0 0 12px;font-size:12px;color:#50575e;">' . esc_html( $description ) . '</p>'; |
| 8021 |
|
| 8022 |
$html .= '<table cellpadding="0" cellspacing="0" border="0" width="100%" style="font-size:12px;">'; |
| 8023 |
foreach ( $shown as $file ) { |
| 8024 |
$file_path = is_array( $file ) ? ( $file['file'] ?? '' ) : (string) $file; |
| 8025 |
$reason = is_array( $file ) ? ( $file['reason'] ?? '' ) : ''; |
| 8026 |
|
| 8027 |
$html .= '<tr>'; |
| 8028 |
$html .= '<td style="padding:4px 0;color:#1d2327;font-family:Consolas,Monaco,monospace;font-size:11px;word-break:break-all;">' . esc_html( $file_path ) . '</td>'; |
| 8029 |
$html .= '</tr>'; |
| 8030 |
|
| 8031 |
if ( $show_reason && ! empty( $reason ) ) { |
| 8032 |
$html .= '<tr>'; |
| 8033 |
$html .= '<td style="padding:0 0 8px 12px;color:#787c82;font-size:11px;font-style:italic;">' . esc_html( $reason ) . '</td>'; |
| 8034 |
$html .= '</tr>'; |
| 8035 |
} |
| 8036 |
} |
| 8037 |
$html .= '</table>'; |
| 8038 |
|
| 8039 |
if ( $total > $max ) { |
| 8040 |
$html .= '<p style="margin:8px 0 0;font-size:12px;color:#787c82;">'; |
| 8041 |
/* translators: %d: Number of additional files */ |
| 8042 |
$html .= sprintf( esc_html__( '... and %d more', 'vigilante' ), $total - $max ); |
| 8043 |
$html .= '</p>'; |
| 8044 |
} |
| 8045 |
|
| 8046 |
$html .= '</div>'; |
| 8047 |
|
| 8048 |
return $html; |
| 8049 |
} |
| 8050 |
|
| 8051 |
/** |
| 8052 |
* Add a file to the ignored list |
| 8053 |
* |
| 8054 |
* The path is stored exactly as it comes: the callers are the AJAX |
| 8055 |
* handlers, which have already read it and checked it against the shared |
| 8056 |
* files and Vigilant's own, and the scan compares the stored strings with |
| 8057 |
* the paths it finds one by one (in_array(), strict). This used to run |
| 8058 |
* sanitize_text_field() over it, which deletes every %XX octet and collapses |
| 8059 |
* repeated spaces, so the entry never matched the file it was made for |
| 8060 |
* (measured on 1 oct 2026 against 3.0.1). Anything that is not a plain |
| 8061 |
* string, is empty or carries tags is not a path and is refused. That guards |
| 8062 |
* a direct caller: the AJAX handlers read the path with wp_strip_all_tags() |
| 8063 |
* first, which shortens a name with a stray < instead of handing it over |
| 8064 |
* intact (see ajax_ignore_file()). |
| 8065 |
* |
| 8066 |
* @param string $file_path Relative file path to ignore. |
| 8067 |
* @return bool |
| 8068 |
*/ |
| 8069 |
public function ignore_file( $file_path ) { |
| 8070 |
if ( ! is_string( $file_path ) || '' === $file_path || wp_strip_all_tags( $file_path ) !== $file_path ) { |
| 8071 |
return false; |
| 8072 |
} |
| 8073 |
|
| 8074 |
$ignored = get_option( 'vigilante_ignored_files', array() ); |
| 8075 |
|
| 8076 |
if ( ! in_array( $file_path, $ignored, true ) ) { |
| 8077 |
$ignored[] = $file_path; |
| 8078 |
return update_option( 'vigilante_ignored_files', $ignored ); |
| 8079 |
} |
| 8080 |
|
| 8081 |
return true; |
| 8082 |
} |
| 8083 |
|
| 8084 |
/** |
| 8085 |
* Remove a file from the ignored list |
| 8086 |
* |
| 8087 |
* @param string $file_path Relative file path to stop ignoring. |
| 8088 |
* @return bool |
| 8089 |
*/ |
| 8090 |
public function unignore_file( $file_path ) { |
| 8091 |
$ignored = get_option( 'vigilante_ignored_files', array() ); |
| 8092 |
$ignored = array_values( array_diff( $ignored, array( $file_path ) ) ); |
| 8093 |
|
| 8094 |
return update_option( 'vigilante_ignored_files', $ignored ); |
| 8095 |
} |
| 8096 |
|
| 8097 |
/** |
| 8098 |
* Get the list of ignored files |
| 8099 |
* |
| 8100 |
* @return array |
| 8101 |
*/ |
| 8102 |
public function get_ignored_files() { |
| 8103 |
return get_option( 'vigilante_ignored_files', array() ); |
| 8104 |
} |
| 8105 |
|
| 8106 |
/** |
| 8107 |
* Clear all ignored files |
| 8108 |
* |
| 8109 |
* @return bool |
| 8110 |
*/ |
| 8111 |
public function clear_ignored_files() { |
| 8112 |
return delete_option( 'vigilante_ignored_files' ); |
| 8113 |
} |
| 8114 |
|
| 8115 |
/** |
| 8116 |
* Get last scan results |
| 8117 |
* |
| 8118 |
* @return array|false |
| 8119 |
*/ |
| 8120 |
public function get_last_scan_results() { |
| 8121 |
return get_option( 'vigilante_last_integrity_results', false ); |
| 8122 |
} |
| 8123 |
|
| 8124 |
/** |
| 8125 |
* Get last scan time |
| 8126 |
* |
| 8127 |
* @return int|false |
| 8128 |
*/ |
| 8129 |
public function get_last_scan_time() { |
| 8130 |
return get_option( 'vigilante_last_integrity_scan', false ); |
| 8131 |
} |
| 8132 |
|
| 8133 |
/** |
| 8134 |
* Clear stored hashes |
| 8135 |
* |
| 8136 |
* @return bool |
| 8137 |
*/ |
| 8138 |
public function clear_hashes() { |
| 8139 |
return $this->database->clear_file_hashes(); |
| 8140 |
} |
| 8141 |
} |