| 1 |
<?php |
| 2 |
/** |
| 3 |
* Component class for handling quarantine functionalities within WP Defender. |
| 4 |
* |
| 5 |
* @package WP_Defender\Component |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WP_Defender\Component; |
| 9 |
|
| 10 |
use WP_Defender\Traits\IO; |
| 11 |
use Calotes\Base\Component; |
| 12 |
use WP_Defender\Traits\Plugin; |
| 13 |
use WP_Defender\Traits\Formats; |
| 14 |
use WP_Defender\Model\Scan_Item; |
| 15 |
use WP_Defender\Behavior\WPMUDEV; |
| 16 |
use WP_Defender\Helper\File as File_Helper; |
| 17 |
use WP_Defender\Model\Setting\Main_Setting; |
| 18 |
use WP_Defender\Component\Scheduler\Scheduler; |
| 19 |
use WP_Defender\Model\Setting\Scan as Scan_Setting; |
| 20 |
use WP_Defender\Model\Quarantine as Quarantine_Model; |
| 21 |
use WP_Filesystem_Base; |
| 22 |
|
| 23 |
/** |
| 24 |
* Service layer for quarantine files functionality. |
| 25 |
* |
| 26 |
* @since 4.0.0 |
| 27 |
*/ |
| 28 |
class Quarantine extends Component { |
| 29 |
|
| 30 |
use Plugin; |
| 31 |
use Formats; |
| 32 |
use IO; |
| 33 |
|
| 34 |
/** |
| 35 |
* Quarantine file expiry limit. |
| 36 |
* |
| 37 |
* @var ?int |
| 38 |
*/ |
| 39 |
private $file_expiry_timestamp; |
| 40 |
|
| 41 |
/** |
| 42 |
* Quarantine model instance. |
| 43 |
* |
| 44 |
* @var Quarantine_Model |
| 45 |
*/ |
| 46 |
private $quarantine_model; |
| 47 |
|
| 48 |
/** |
| 49 |
* Scheduler instance. |
| 50 |
* |
| 51 |
* @var Scheduler |
| 52 |
*/ |
| 53 |
private $scheduler; |
| 54 |
|
| 55 |
/** |
| 56 |
* Scan setting instance. |
| 57 |
* |
| 58 |
* @var Scan_Setting |
| 59 |
*/ |
| 60 |
private $scan_setting; |
| 61 |
|
| 62 |
/** |
| 63 |
* Main setting instance. |
| 64 |
* |
| 65 |
* @var Main_Setting |
| 66 |
*/ |
| 67 |
private $main_setting; |
| 68 |
|
| 69 |
/** |
| 70 |
* WPMUDEV instance. |
| 71 |
* |
| 72 |
* @var WPMUDEV |
| 73 |
*/ |
| 74 |
private $wpmudev; |
| 75 |
|
| 76 |
/** |
| 77 |
* Hook tag to delete expired files. |
| 78 |
*/ |
| 79 |
private const CRON_HOOK = 'wpdef_quarantine_delete_expired'; |
| 80 |
|
| 81 |
/** |
| 82 |
* Quarantine directory name. |
| 83 |
*/ |
| 84 |
private const QUARANTINE_DIRECTORY = '.defender-security-quarantine'; |
| 85 |
|
| 86 |
/** |
| 87 |
* Quarantine directory permission. |
| 88 |
*/ |
| 89 |
public const QUARANTINE_DIRECTORY_PERMISSION = 0755; |
| 90 |
|
| 91 |
/** |
| 92 |
* Constructor for the Quarantine component. |
| 93 |
*/ |
| 94 |
public function __construct() { |
| 95 |
// Dependencies. |
| 96 |
$this->quarantine_model = wd_di()->get( Quarantine_Model::class ); |
| 97 |
$this->scheduler = wd_di()->get( Scheduler::class ); |
| 98 |
$this->scan_setting = wd_di()->get( Scan_Setting::class ); |
| 99 |
$this->main_setting = wd_di()->get( Main_Setting::class ); |
| 100 |
$this->wpmudev = wd_di()->get( WPMUDEV::class ); |
| 101 |
|
| 102 |
$this->file_expiry_timestamp = $this->set_expiry_timestamp(); |
| 103 |
|
| 104 |
$this->init(); |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* Get file permission in octal number. |
| 109 |
* |
| 110 |
* @param string $file File path. |
| 111 |
* |
| 112 |
* @return int File permission octal notation. |
| 113 |
*/ |
| 114 |
private function get_octet_fileperms( $file ): int { |
| 115 |
clearstatcache(); |
| 116 |
|
| 117 |
return (int) decoct( fileperms( $file ) & 0777 ); |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* Prepare value for all columns of quarantine table. |
| 122 |
* |
| 123 |
* @param Scan_Item $scan_item Scan item model object. |
| 124 |
* |
| 125 |
* @return Quarantine_Model Quarantine model object. |
| 126 |
*/ |
| 127 |
private function prepare_file_metadata( Scan_Item $scan_item ): Quarantine_Model { |
| 128 |
$file = $scan_item->raw_data['file']; |
| 129 |
|
| 130 |
$file_hash = (string) sha1_file( $file ) . uniqid(); |
| 131 |
|
| 132 |
$path_info = pathinfo( $file ); |
| 133 |
|
| 134 |
$mime = (string) mime_content_type( $file ); |
| 135 |
|
| 136 |
$perms = $this->get_octet_fileperms( $file ); |
| 137 |
|
| 138 |
$file_owner = (string) fileowner( $file ); |
| 139 |
|
| 140 |
$file_group = (string) filegroup( $file ); |
| 141 |
|
| 142 |
$plugin_headers = $this->get_plugin_headers( $file ); |
| 143 |
$plugin_headers = reset( $plugin_headers ); |
| 144 |
|
| 145 |
$file_version = isset( $plugin_headers['Version'] ) ? $plugin_headers['Version'] : ''; |
| 146 |
|
| 147 |
$mtime = wp_date( 'Y-m-d H:i:s', (int) filemtime( $file ) ); |
| 148 |
|
| 149 |
$source_slug = $this->get_plugin_directory_name( $file ); |
| 150 |
|
| 151 |
$created_time = wp_date( 'Y-m-d H:i:s' ); |
| 152 |
|
| 153 |
$created_by = get_current_user_id(); |
| 154 |
|
| 155 |
$file_category = $this->quarantine_model::WP_PLUGIN; |
| 156 |
|
| 157 |
$this->quarantine_model->defender_scan_item_id = $scan_item->id; |
| 158 |
$this->quarantine_model->file_hash = $file_hash; |
| 159 |
$this->quarantine_model->file_full_path = $file; |
| 160 |
$this->quarantine_model->file_original_name = $path_info['filename']; |
| 161 |
$this->quarantine_model->file_extension = isset( $path_info['extension'] ) ? $path_info['extension'] : ''; |
| 162 |
$this->quarantine_model->file_mime_type = $mime; |
| 163 |
$this->quarantine_model->file_rw_permission = $perms; |
| 164 |
$this->quarantine_model->file_owner = $file_owner; |
| 165 |
$this->quarantine_model->file_group = $file_group; |
| 166 |
$this->quarantine_model->file_version = $file_version; |
| 167 |
$this->quarantine_model->file_category = $file_category; |
| 168 |
$this->quarantine_model->file_modified_time = $mtime; |
| 169 |
$this->quarantine_model->source_slug = $source_slug; |
| 170 |
$this->quarantine_model->created_time = $created_time; |
| 171 |
$this->quarantine_model->created_by = $created_by; |
| 172 |
|
| 173 |
return $this->quarantine_model; |
| 174 |
} |
| 175 |
|
| 176 |
/** |
| 177 |
* Logics to process before file manipulation. |
| 178 |
* |
| 179 |
* @param string $file_path File path of the plugin. |
| 180 |
* |
| 181 |
* @return array Array of plugin data. Keys plugin_basename, is_active & is_network_active. |
| 182 |
*/ |
| 183 |
private function before_file_transaction( string $file_path ): array { |
| 184 |
// Plugin active state. |
| 185 |
$plugin_basename = plugin_basename( $file_path ); |
| 186 |
$is_active = is_plugin_active( $plugin_basename ); |
| 187 |
$is_network_active = is_plugin_active_for_network( $plugin_basename ); |
| 188 |
|
| 189 |
// Deactivate plugin which are already activated. No effect on already deactivated plugin. |
| 190 |
if ( $is_active ) { |
| 191 |
deactivate_plugins( $plugin_basename, true, $is_network_active ); |
| 192 |
} |
| 193 |
|
| 194 |
$result = array( |
| 195 |
'plugin_basename' => $plugin_basename, |
| 196 |
'is_active' => $is_active, |
| 197 |
'is_network_active' => $is_network_active, |
| 198 |
); |
| 199 |
|
| 200 |
$this->log_wrapper( $result ); |
| 201 |
|
| 202 |
return $result; |
| 203 |
} |
| 204 |
|
| 205 |
/** |
| 206 |
* Logics to process before file manipulation. |
| 207 |
* |
| 208 |
* @param array $data Data needs to activate the plugin. |
| 209 |
*/ |
| 210 |
private function after_file_transaction( array $data ): void { |
| 211 |
$this->log_wrapper( $data ); |
| 212 |
|
| 213 |
// Activate plugin if already activates. |
| 214 |
if ( $data['is_active'] ) { |
| 215 |
activate_plugins( $data['plugin_basename'], '', $data['is_network_active'], true ); |
| 216 |
} |
| 217 |
} |
| 218 |
|
| 219 |
/** |
| 220 |
* Wrapper method which quarantine the file. |
| 221 |
* Track plugin activation/deactivation state. |
| 222 |
* Decide to deactivate plugin based on the state. |
| 223 |
* Do core quarantine process. |
| 224 |
* Reinstate previous state of plugin activation/deactivation. |
| 225 |
* |
| 226 |
* @param Scan_Item $scan_item Scan item model object. |
| 227 |
* @param string $parent_action Parent action. |
| 228 |
* |
| 229 |
* @return array Index message: describes what happened. |
| 230 |
* Index success: true if file quarantined and quarantine record |
| 231 |
* created else false. |
| 232 |
*/ |
| 233 |
public function quarantine_file( Scan_Item $scan_item, string $parent_action ): array { |
| 234 |
$before_file_transaction = $this->before_file_transaction( $scan_item->raw_data['file'] ); |
| 235 |
|
| 236 |
// Do quarantine file processing. |
| 237 |
$action = $this->do_quarantine( $scan_item, $parent_action ); |
| 238 |
|
| 239 |
$this->after_file_transaction( $before_file_transaction ); |
| 240 |
|
| 241 |
$this->wpmudev->schedule_hub_sync(); |
| 242 |
|
| 243 |
return $action; |
| 244 |
} |
| 245 |
|
| 246 |
/** |
| 247 |
* Transfer file from source to destination. |
| 248 |
* Depends on the parent action if action is repair then copy file to destination. |
| 249 |
* Else move file to destination. |
| 250 |
* |
| 251 |
* @param string $from Source file path. |
| 252 |
* @param string $to Destination file path. |
| 253 |
* @param string $parent_action Parent action. As of now only two actions repair or delete. |
| 254 |
* |
| 255 |
* @return bool True on operation succeeded or False on operation failed. |
| 256 |
*/ |
| 257 |
private function file_transfer( string $from, string $to, string $parent_action ): bool { |
| 258 |
global $wp_filesystem; |
| 259 |
if ( 'repair' === $parent_action ) { |
| 260 |
return copy( $from, $to ); |
| 261 |
} |
| 262 |
return $wp_filesystem->move( $from, $to, true ); |
| 263 |
} |
| 264 |
|
| 265 |
/** |
| 266 |
* Core method which Quarantine the file. |
| 267 |
* Do only file processing and DB handling. |
| 268 |
* |
| 269 |
* @param Scan_Item $scan_item Scan item model object. |
| 270 |
* @param string $parent_action Parent action. |
| 271 |
* |
| 272 |
* @return array Index message: describes what happened. |
| 273 |
* Index success: true if file quarantined and quarantine record |
| 274 |
* created else false. |
| 275 |
*/ |
| 276 |
private function do_quarantine( Scan_Item $scan_item, string $parent_action ): array { |
| 277 |
$quarantine_model = $this->prepare_file_metadata( $scan_item ); |
| 278 |
|
| 279 |
$this->log_wrapper( $quarantine_model, 'Do quarantine: Prepare file metadata' ); |
| 280 |
$this->log_wrapper( $parent_action, 'Do quarantine: Parent action name' ); |
| 281 |
|
| 282 |
$file_name_with_extension = $quarantine_model->file_original_name . '.' . $quarantine_model->file_extension; |
| 283 |
|
| 284 |
$is_renamed = $this->file_transfer( |
| 285 |
$quarantine_model->file_full_path, |
| 286 |
$this->get_quarantined_file_path( $quarantine_model->file_hash ), |
| 287 |
$parent_action |
| 288 |
); |
| 289 |
|
| 290 |
if ( ! $is_renamed ) { |
| 291 |
$result = array( |
| 292 |
'message' => sprintf( |
| 293 |
/* translators: 1: Filename with extension */ |
| 294 |
esc_html__( 'Failed to quarantine the file %1$s.', 'defender-security' ), |
| 295 |
'<strong>' . $file_name_with_extension . '</strong>' |
| 296 |
), |
| 297 |
'success' => false, |
| 298 |
); |
| 299 |
|
| 300 |
$this->log_wrapper( $result ); |
| 301 |
|
| 302 |
return $result; |
| 303 |
} |
| 304 |
|
| 305 |
$saved = $quarantine_model->save(); |
| 306 |
|
| 307 |
if ( ! is_int( $saved ) ) { |
| 308 |
$result = array( |
| 309 |
'message' => sprintf( |
| 310 |
/* translators: 1: Filename with extension */ |
| 311 |
esc_html__( 'Failed to add quarantine record in DB for the file %1$s.', 'defender-security' ), |
| 312 |
'<strong>' . $file_name_with_extension . '</strong>' |
| 313 |
), |
| 314 |
'success' => false, |
| 315 |
); |
| 316 |
|
| 317 |
$this->log_wrapper( $result ); |
| 318 |
|
| 319 |
return $result; |
| 320 |
} |
| 321 |
|
| 322 |
$message = sprintf( |
| 323 |
/* translators: 1: Filename with extension */ |
| 324 |
esc_html__( 'Quarantined the file %1$s and deleted the source file successfully.', 'defender-security' ), |
| 325 |
'<strong>' . $file_name_with_extension . '</strong>' |
| 326 |
); |
| 327 |
|
| 328 |
if ( 'repair' === $parent_action ) { |
| 329 |
$plugin_write_handler = $this->plugin_write_handler( $quarantine_model ); |
| 330 |
|
| 331 |
if ( false === $plugin_write_handler['success'] ) { |
| 332 |
$this->log_wrapper( $plugin_write_handler, 'Plugin write failed' ); |
| 333 |
|
| 334 |
$this->restore_file( $scan_item ); |
| 335 |
|
| 336 |
return $plugin_write_handler; |
| 337 |
} |
| 338 |
|
| 339 |
$message = sprintf( |
| 340 |
/* translators: 1: Filename with extension */ |
| 341 |
esc_html__( |
| 342 |
'Quarantined the file %1$s and repaired the source file successfully.', |
| 343 |
'defender-security' |
| 344 |
), |
| 345 |
'<strong>' . $file_name_with_extension . '</strong>' |
| 346 |
); |
| 347 |
} |
| 348 |
|
| 349 |
$result = array( |
| 350 |
'message' => $message, |
| 351 |
'success' => true, |
| 352 |
); |
| 353 |
|
| 354 |
$scan_item->delete_by_id( $scan_item->id ); |
| 355 |
|
| 356 |
$this->log_wrapper( $result ); |
| 357 |
|
| 358 |
return $result; |
| 359 |
} |
| 360 |
|
| 361 |
/** |
| 362 |
* Restore the quarantined file either using Scan_Item object or Quarantine record primary key. |
| 363 |
* |
| 364 |
* @param Scan_Item|int $entity To determine which file to restore. |
| 365 |
* |
| 366 |
* @return array Index message: describes what happened. |
| 367 |
* Index success: true if file moved and quarantine record |
| 368 |
* removed else false. |
| 369 |
*/ |
| 370 |
public function restore_file( $entity ): array { |
| 371 |
global $wp_filesystem; |
| 372 |
// Initialize the WP filesystem, no more using 'file-put-contents' function. |
| 373 |
if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) { |
| 374 |
require_once ABSPATH . '/wp-admin/includes/file.php'; |
| 375 |
WP_Filesystem(); |
| 376 |
} |
| 377 |
if ( $entity instanceof Scan_Item ) { |
| 378 |
$file_metadata = $this->get_record_by_scan_item( $entity ); |
| 379 |
} elseif ( is_int( $entity ) ) { |
| 380 |
$file_metadata = $this->quarantine_model->find_by_id( $entity ); |
| 381 |
} |
| 382 |
|
| 383 |
$this->log_wrapper( $file_metadata ); |
| 384 |
|
| 385 |
if ( |
| 386 |
! isset( |
| 387 |
$file_metadata, |
| 388 |
$file_metadata->file_hash, |
| 389 |
$file_metadata->file_full_path, |
| 390 |
$file_metadata->id, |
| 391 |
$file_metadata->file_original_name, |
| 392 |
$file_metadata->file_extension |
| 393 |
) |
| 394 |
) { |
| 395 |
$result = array( |
| 396 |
'message' => esc_html__( 'Record not exists in DB.', 'defender-security' ), |
| 397 |
'success' => false, |
| 398 |
); |
| 399 |
|
| 400 |
$this->log_wrapper( $result ); |
| 401 |
|
| 402 |
return $result; |
| 403 |
} |
| 404 |
|
| 405 |
$file_name_with_extension = $file_metadata->file_original_name . '.' . $file_metadata->file_extension; |
| 406 |
|
| 407 |
if ( ! $this->is_quarantine_file_exists( $file_metadata->file_hash ) ) { |
| 408 |
$result = array( |
| 409 |
'message' => esc_html__( 'Quarantined file doesn\'t exist in the quarantine directory.', 'defender-security' ), |
| 410 |
'success' => false, |
| 411 |
); |
| 412 |
|
| 413 |
$this->log_wrapper( |
| 414 |
$result, |
| 415 |
'External error: File System Error or Quarantined file deleted by someone using OS CMD' |
| 416 |
); |
| 417 |
|
| 418 |
return $result; |
| 419 |
} |
| 420 |
|
| 421 |
$plugin_headers = $this->get_plugin_headers( $file_metadata->file_full_path ); |
| 422 |
|
| 423 |
if ( array() === $plugin_headers ) { |
| 424 |
$result = array( |
| 425 |
'message' => sprintf( |
| 426 |
/* translators: 1: Filename with extension */ |
| 427 |
esc_html__( 'Plugin main file missing for %1$s file.', 'defender-security' ), |
| 428 |
'<strong>' . $file_name_with_extension . '</strong>' |
| 429 |
), |
| 430 |
'success' => false, |
| 431 |
); |
| 432 |
|
| 433 |
$this->log_wrapper( $result ); |
| 434 |
} |
| 435 |
|
| 436 |
$quarantined_file_path = $this->get_quarantined_file_path( $file_metadata->file_hash ); |
| 437 |
|
| 438 |
$before_file_transaction = $this->before_file_transaction( $file_metadata->file_full_path ); |
| 439 |
|
| 440 |
$is_renamed = $wp_filesystem->move( $quarantined_file_path, $file_metadata->file_full_path, true ); |
| 441 |
|
| 442 |
$this->after_file_transaction( $before_file_transaction ); |
| 443 |
|
| 444 |
if ( ! $is_renamed ) { |
| 445 |
$result = array( |
| 446 |
'message' => esc_html__( 'Failed to restore quarantined file.', 'defender-security' ), |
| 447 |
'success' => false, |
| 448 |
); |
| 449 |
|
| 450 |
$this->log_wrapper( $result ); |
| 451 |
|
| 452 |
return $result; |
| 453 |
} |
| 454 |
|
| 455 |
$is_delete_success = $this->quarantine_model->delete( (int) $file_metadata->id ); |
| 456 |
|
| 457 |
if ( ! $is_delete_success ) { |
| 458 |
$result = array( |
| 459 |
'message' => esc_html__( 'Failed to remove quarantine record from DB.', 'defender-security' ), |
| 460 |
'success' => false, |
| 461 |
); |
| 462 |
|
| 463 |
$this->log_wrapper( $result ); |
| 464 |
|
| 465 |
return $result; |
| 466 |
} |
| 467 |
|
| 468 |
$result = array( |
| 469 |
'message' => sprintf( |
| 470 |
/* translators: 1: Filename with extension */ |
| 471 |
esc_html__( 'Restored %1$s', 'defender-security' ), |
| 472 |
'<strong>' . $file_name_with_extension . '</strong>' |
| 473 |
), |
| 474 |
'success' => true, |
| 475 |
); |
| 476 |
|
| 477 |
$this->log_wrapper( $result ); |
| 478 |
|
| 479 |
$this->wpmudev->schedule_hub_sync(); |
| 480 |
|
| 481 |
return $result; |
| 482 |
} |
| 483 |
|
| 484 |
/** |
| 485 |
* Return Quarantine model. |
| 486 |
* |
| 487 |
* @param Scan_Item $scan_item Scan_Item object which individual record of scan data. |
| 488 |
* |
| 489 |
* @return mixed Return Quarantine model if exists. |
| 490 |
*/ |
| 491 |
private function get_record_by_scan_item( Scan_Item $scan_item ) { |
| 492 |
return $this->quarantine_model->select_restore_detail( $scan_item ); |
| 493 |
} |
| 494 |
|
| 495 |
/** |
| 496 |
* Check if a file is quarantined. |
| 497 |
* |
| 498 |
* @param Scan_Item $scan_item The scan item to check. |
| 499 |
* |
| 500 |
* @return bool True if the file is quarantined, false otherwise. |
| 501 |
*/ |
| 502 |
private function is_quarantined( Scan_Item $scan_item ): bool { |
| 503 |
$quarantined_record = $this->quarantine_model->select_by_file_full_path( $scan_item->raw_data['file'] ); |
| 504 |
|
| 505 |
$is_file_exists = false; |
| 506 |
|
| 507 |
if ( isset( $quarantined_record[0] ) ) { |
| 508 |
$is_file_exists = $this->is_quarantine_file_exists( $quarantined_record[0]->file_hash ); |
| 509 |
} |
| 510 |
|
| 511 |
return $is_file_exists; |
| 512 |
} |
| 513 |
|
| 514 |
/** |
| 515 |
* Get the path to the quarantine directory. |
| 516 |
* |
| 517 |
* @return string The path to the quarantine directory. |
| 518 |
*/ |
| 519 |
private function get_quarantine_directory(): string { |
| 520 |
global $wp_filesystem; |
| 521 |
// Initialize the WP filesystem, no more using 'file-put-contents' function. |
| 522 |
if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) { |
| 523 |
require_once ABSPATH . '/wp-admin/includes/file.php'; |
| 524 |
WP_Filesystem(); |
| 525 |
} |
| 526 |
$quarantine_dir = WP_CONTENT_DIR . DIRECTORY_SEPARATOR . self::QUARANTINE_DIRECTORY; |
| 527 |
|
| 528 |
if ( ! is_dir( $quarantine_dir ) ) { |
| 529 |
$wp_filesystem->mkdir( $quarantine_dir, self::QUARANTINE_DIRECTORY_PERMISSION ); |
| 530 |
} |
| 531 |
$file_helper = wd_di()->get( File_Helper::class ); |
| 532 |
$file_helper->maybe_dir_access_deny( $quarantine_dir ); |
| 533 |
|
| 534 |
return $quarantine_dir; |
| 535 |
} |
| 536 |
|
| 537 |
/** |
| 538 |
* Get the full path to a quarantined file. |
| 539 |
* |
| 540 |
* @param string $file_hash The hash of the file. |
| 541 |
* |
| 542 |
* @return string The full path to the quarantined file. |
| 543 |
*/ |
| 544 |
private function get_quarantined_file_path( string $file_hash ): string { |
| 545 |
return $this->get_quarantine_directory() . DIRECTORY_SEPARATOR . $file_hash . '.restore'; |
| 546 |
} |
| 547 |
|
| 548 |
/** |
| 549 |
* Writes plugin file by fetching from wp.org repo. |
| 550 |
* |
| 551 |
* @param Quarantine_Model $quarantine_model SQL ORM object. |
| 552 |
* |
| 553 |
* @return array Index message: describes what happened. |
| 554 |
* Index success: true if writes successfully |
| 555 |
* else false on failed to write or WP plugin repository errors. |
| 556 |
*/ |
| 557 |
private function plugin_write_handler( Quarantine_Model $quarantine_model ): array { |
| 558 |
global $wp_filesystem; |
| 559 |
// Initialize the WP filesystem, no more using 'file-put-contents' function. |
| 560 |
if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) { |
| 561 |
require_once ABSPATH . '/wp-admin/includes/file.php'; |
| 562 |
WP_Filesystem(); |
| 563 |
} |
| 564 |
$plugin_directory_name = $this->get_plugin_directory_name( $quarantine_model->file_full_path ); |
| 565 |
|
| 566 |
if ( ! $this->is_likely_wporg_slug( $plugin_directory_name ) ) { |
| 567 |
return array( |
| 568 |
'message' => esc_html__( 'Make sure plugin exists in WordPress plugin repository.', 'defender-security' ), |
| 569 |
'success' => false, |
| 570 |
); |
| 571 |
} |
| 572 |
|
| 573 |
$plugin_relative_path = $this->get_plugin_relative_path( $quarantine_model->file_full_path ); |
| 574 |
$is_plugin_in_wp_org = $this->check_plugin_on_wp_org( $plugin_directory_name ); |
| 575 |
|
| 576 |
$file_name_with_extension = $quarantine_model->file_original_name . '.' . $quarantine_model->file_extension; |
| 577 |
$generic_error = array( |
| 578 |
'message' => sprintf( |
| 579 |
/* translators: 1: Filename with extension */ |
| 580 |
esc_html__( 'Failed to write the file %1$s.', 'defender-security' ), |
| 581 |
'<strong>' . $file_name_with_extension . '</strong>' |
| 582 |
), |
| 583 |
'success' => false, |
| 584 |
); |
| 585 |
|
| 586 |
if ( |
| 587 |
isset( $is_plugin_in_wp_org['success'] ) && |
| 588 |
true === $is_plugin_in_wp_org['success'] |
| 589 |
) { |
| 590 |
$file_url = $this->get_file_url( |
| 591 |
$plugin_directory_name, |
| 592 |
$quarantine_model->file_version, |
| 593 |
$plugin_relative_path |
| 594 |
); |
| 595 |
|
| 596 |
$file_content = $this->get_url_content( $file_url ); |
| 597 |
|
| 598 |
$is_written = $wp_filesystem->put_contents( $quarantine_model->file_full_path, $file_content ); |
| 599 |
|
| 600 |
if ( $is_written ) { |
| 601 |
return array( |
| 602 |
'message' => sprintf( |
| 603 |
/* translators: 1: Filename with extension */ |
| 604 |
esc_html__( 'Writes the file %1$s successfully.', 'defender-security' ), |
| 605 |
'<strong>' . $file_name_with_extension . '</strong>' |
| 606 |
), |
| 607 |
'success' => true, |
| 608 |
); |
| 609 |
} |
| 610 |
|
| 611 |
return $generic_error; |
| 612 |
} elseif ( |
| 613 |
isset( $is_plugin_in_wp_org['success'], $is_plugin_in_wp_org['message'] ) && |
| 614 |
false === $is_plugin_in_wp_org['success'] |
| 615 |
) { |
| 616 |
$error = $is_plugin_in_wp_org['message']; |
| 617 |
|
| 618 |
return array( |
| 619 |
'message' => sprintf( |
| 620 |
/* translators: 1: WordPress API error message */ |
| 621 |
esc_html__( 'WordPress remote error: %1$s', 'defender-security' ), |
| 622 |
'<strong>' . $error . '</strong>' |
| 623 |
), |
| 624 |
'success' => false, |
| 625 |
); |
| 626 |
} |
| 627 |
|
| 628 |
return $generic_error; |
| 629 |
} |
| 630 |
|
| 631 |
/** |
| 632 |
* Check if a quarantined file exists. |
| 633 |
* |
| 634 |
* @param string $file_hash The hash of the file. |
| 635 |
* |
| 636 |
* @return bool True if the file exists, false otherwise. |
| 637 |
*/ |
| 638 |
private function is_quarantine_file_exists( string $file_hash ): bool { |
| 639 |
return file_exists( $this->get_quarantined_file_path( $file_hash ) ); |
| 640 |
} |
| 641 |
|
| 642 |
/** |
| 643 |
* Get a collection of quarantined files. |
| 644 |
* |
| 645 |
* @return array An array of quarantined files. |
| 646 |
*/ |
| 647 |
public function quarantine_collection(): array { |
| 648 |
$collections = $this->quarantine_model->quarantine_collection(); |
| 649 |
|
| 650 |
foreach ( $collections as $index => $collection ) { |
| 651 |
$name = ''; |
| 652 |
// Is the plugin file? |
| 653 |
if ( 0 === strpos( realpath( $collection['file_full_path'] ), realpath( WP_PLUGIN_DIR ) ) ) { |
| 654 |
$plugin_headers = $this->get_plugin_headers( $collection['file_full_path'] ); |
| 655 |
if ( is_array( $plugin_headers ) ) { |
| 656 |
$plugin_headers = reset( $plugin_headers ); |
| 657 |
|
| 658 |
$name = isset( $plugin_headers['Name'] ) ? $plugin_headers['Name'] : ''; |
| 659 |
} |
| 660 |
} |
| 661 |
|
| 662 |
$collections[ $index ]['name'] = $name; |
| 663 |
|
| 664 |
$file_modified_timestamp = strtotime( $collection['file_modified_time'] ); |
| 665 |
$created_timestamp = strtotime( $collection['created_time'] ); |
| 666 |
|
| 667 |
$collections[ $index ]['file_modified_time'] = false !== $file_modified_timestamp |
| 668 |
? wp_date( 'M j, Y', $file_modified_timestamp ) |
| 669 |
: $collection['file_modified_time']; |
| 670 |
|
| 671 |
$collections[ $index ]['created_time'] = false !== $created_timestamp |
| 672 |
? wp_date( 'M j, Y', $created_timestamp ) |
| 673 |
: $collection['created_time']; |
| 674 |
} |
| 675 |
|
| 676 |
return $collections; |
| 677 |
} |
| 678 |
|
| 679 |
/** |
| 680 |
* Hard delete the quarantined file and remove the DB record. |
| 681 |
* |
| 682 |
* @param int $id Primary key of the record need to be deleted with associated file. |
| 683 |
* |
| 684 |
* @return bool True on success else false. |
| 685 |
*/ |
| 686 |
public function delete_quarantined_file( int $id ): bool { |
| 687 |
$file_metadata = $this->quarantine_model->find_by_id( $id ); |
| 688 |
|
| 689 |
$this->log_wrapper( $file_metadata, 'On deletion: File metadata' ); |
| 690 |
|
| 691 |
$is_file_exists = $this->is_quarantine_file_exists( $file_metadata->file_hash ); |
| 692 |
|
| 693 |
$this->log_wrapper( 'File exists? ' . $is_file_exists ); |
| 694 |
|
| 695 |
if ( |
| 696 |
! is_null( $file_metadata ) && |
| 697 |
$is_file_exists |
| 698 |
) { |
| 699 |
wp_delete_file( |
| 700 |
$this->get_quarantined_file_path( |
| 701 |
$file_metadata->file_hash |
| 702 |
) |
| 703 |
); |
| 704 |
$is_model_deleted = $this->quarantine_model->delete( $id ); |
| 705 |
|
| 706 |
$this->log_wrapper( 'Model deleted? ' . $is_model_deleted ); |
| 707 |
|
| 708 |
$scan = \WP_Defender\Model\Scan::get_last(); |
| 709 |
$scan->remove_related_issue_by( $file_metadata->file_full_path, '' ); |
| 710 |
|
| 711 |
$this->wpmudev->schedule_hub_sync(); |
| 712 |
|
| 713 |
return $is_model_deleted; |
| 714 |
} |
| 715 |
|
| 716 |
$this->log_wrapper( 'Check SQL row & quarantined file exists for PK: ' . $id ); |
| 717 |
|
| 718 |
return false; |
| 719 |
} |
| 720 |
|
| 721 |
/** |
| 722 |
* Delete files which are older the expiry time limit. |
| 723 |
*/ |
| 724 |
private function delete_old_file(): void { |
| 725 |
if ( is_int( $this->file_expiry_timestamp ) ) { |
| 726 |
$expiry_limit = wp_date( 'Y-m-d H:i:s', $this->file_expiry_timestamp ); |
| 727 |
|
| 728 |
$old_records = $this->quarantine_model->get_old_records( $expiry_limit ); |
| 729 |
|
| 730 |
foreach ( $old_records as $file_id ) { |
| 731 |
$this->delete_quarantined_file( (int) $file_id['id'] ); |
| 732 |
} |
| 733 |
} |
| 734 |
} |
| 735 |
|
| 736 |
/** |
| 737 |
* Cron schedule delete old files. |
| 738 |
*/ |
| 739 |
public function cron_process(): void { |
| 740 |
$this->delete_old_file(); |
| 741 |
} |
| 742 |
|
| 743 |
/** |
| 744 |
* Invoke all init methods. |
| 745 |
*/ |
| 746 |
public function init(): void { |
| 747 |
$interval = $this->scheduler->get_cron_schedule_interval( |
| 748 |
$this->scan_setting->quarantine_expire_schedule |
| 749 |
); |
| 750 |
|
| 751 |
if ( false !== $interval ) { |
| 752 |
/** |
| 753 |
* Delete old quarantined files. |
| 754 |
* |
| 755 |
* @var Network_Cron_Manager $network_cron_manager |
| 756 |
*/ |
| 757 |
$network_cron_manager = wd_di()->get( Network_Cron_Manager::class ); |
| 758 |
$network_cron_manager->register_callback( |
| 759 |
self::CRON_HOOK, |
| 760 |
array( $this, 'cron_process' ), |
| 761 |
$interval |
| 762 |
); |
| 763 |
} |
| 764 |
|
| 765 |
$this->get_quarantine_directory(); |
| 766 |
} |
| 767 |
|
| 768 |
/** |
| 769 |
* List of cron schedules utilized by quarantine expiry settings. |
| 770 |
* |
| 771 |
* @return array List of WP cron schedules relevant to quarantine period. |
| 772 |
*/ |
| 773 |
public function cron_schedules(): array { |
| 774 |
return $this->scheduler->filter_cron_schedules( |
| 775 |
array( |
| 776 |
'thirty_days', |
| 777 |
'sixty_days', |
| 778 |
'ninety_days', |
| 779 |
'six_months', |
| 780 |
'one_year', |
| 781 |
) |
| 782 |
); |
| 783 |
} |
| 784 |
|
| 785 |
/** |
| 786 |
* Calculate threshold timestamp. |
| 787 |
* This timestamp is used to delete sql record created before threshold |
| 788 |
* timestamp and it's associated file. |
| 789 |
* |
| 790 |
* @return ?int Threshold timestamp. |
| 791 |
*/ |
| 792 |
private function set_expiry_timestamp(): ?int { |
| 793 |
$schedule_name = $this->scan_setting->quarantine_expire_schedule; |
| 794 |
|
| 795 |
$get_schedule = $this->scheduler->filter_cron_schedules( |
| 796 |
array( $schedule_name ) |
| 797 |
); |
| 798 |
|
| 799 |
if ( isset( $get_schedule[ $schedule_name ] ) ) { |
| 800 |
$now = strtotime( 'now' ); |
| 801 |
$now = ! is_int( $now ) ? (int) $now : $now; |
| 802 |
$quarantined_time_threshold = $now - $get_schedule[ $schedule_name ]['interval']; |
| 803 |
|
| 804 |
return $quarantined_time_threshold; |
| 805 |
} |
| 806 |
|
| 807 |
return null; |
| 808 |
} |
| 809 |
|
| 810 |
/** |
| 811 |
* Reschedule the cron. |
| 812 |
* This method helps to change schedule when expiry period changed and delete |
| 813 |
* quarantine sql records and associated file if the quarantined time is expired. |
| 814 |
* |
| 815 |
* @param string $prev Previously selected cron interval. |
| 816 |
* @param string $current Currently selected cron interval. |
| 817 |
*/ |
| 818 |
public function reschedule_file_expiry_cron( string $prev, string $current ): void { |
| 819 |
// If prev cron interval and current cron interval same then early return. |
| 820 |
if ( 0 === strcmp( $prev, $current ) ) { |
| 821 |
return; |
| 822 |
} |
| 823 |
|
| 824 |
// Else if prev cron interval not equal to current cron interval. |
| 825 |
// Replace the previous scheduler with new. |
| 826 |
$this->scheduler->override_schedule( self::CRON_HOOK, $current ); |
| 827 |
|
| 828 |
// Invoke cron process immediately to delete expired file related to current interval. |
| 829 |
$this->cron_process(); |
| 830 |
} |
| 831 |
|
| 832 |
/** |
| 833 |
* Get quarantine directory URL. |
| 834 |
* |
| 835 |
* @return string Quarantine directory URL. |
| 836 |
*/ |
| 837 |
public function quarantine_directory_url(): string { |
| 838 |
return content_url( self::QUARANTINE_DIRECTORY ); |
| 839 |
} |
| 840 |
|
| 841 |
/** |
| 842 |
* Actions to accomplish before plugin uninstallation. |
| 843 |
* If Remove settings chosen then directly remove data i.e. without archiving the quarantined file and table data. |
| 844 |
*/ |
| 845 |
public function on_uninstall(): void { |
| 846 |
$method_name = 'data_' . $this->main_setting->uninstall_quarantine; |
| 847 |
|
| 848 |
if ( method_exists( $this, $method_name ) ) { |
| 849 |
$this->$method_name(); |
| 850 |
} |
| 851 |
} |
| 852 |
|
| 853 |
/** |
| 854 |
* Remove quarantine file system & table data. |
| 855 |
*/ |
| 856 |
private function data_remove(): void { |
| 857 |
$this->delete_dir( $this->get_quarantine_directory() ); |
| 858 |
$this->quarantine_model->drop_table(); |
| 859 |
} |
| 860 |
|
| 861 |
/** |
| 862 |
* Log wrapper method. |
| 863 |
* |
| 864 |
* @param mixed $message Details need to be write accepts string or array or object. |
| 865 |
* @param string $custom_trace_title Description about custom trace event. |
| 866 |
* @param bool $in_depth_debug If true in depth trace else if false then simple trace. |
| 867 |
* @param int $in_depth_limit How much deep to trace. Works only for true assigned for $in_depth_debug. |
| 868 |
* @param string $category Log filename. |
| 869 |
*/ |
| 870 |
private function log_wrapper( |
| 871 |
$message, |
| 872 |
string $custom_trace_title = '', |
| 873 |
bool $in_depth_debug = false, |
| 874 |
int $in_depth_limit = 1, |
| 875 |
string $category = 'quarantine' |
| 876 |
): void { |
| 877 |
$this->log( 'Backtrace Summary:', $category ); |
| 878 |
/** |
| 879 |
* Ignore WordPress.PHP.DevelopmentFunctions.error_log_wp_debug_backtrace_summary |
| 880 |
* Why? |
| 881 |
* This is an internal function and it's using to debug error in production. |
| 882 |
*/ |
| 883 |
$this->log( wp_debug_backtrace_summary( null, 1, false ), $category ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_wp_debug_backtrace_summary |
| 884 |
|
| 885 |
$this->log( 'Custom Trace:', $category ); |
| 886 |
|
| 887 |
if ( '' !== $custom_trace_title ) { |
| 888 |
$this->log( $custom_trace_title, $category ); |
| 889 |
} |
| 890 |
|
| 891 |
$this->log( $message, $category ); |
| 892 |
|
| 893 |
if ( true === $in_depth_debug ) { |
| 894 |
$this->log( 'Detailed Debug:', $category ); |
| 895 |
/** |
| 896 |
* Ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace |
| 897 |
* Why? |
| 898 |
* This is an internal function and it's using to debug error in production. |
| 899 |
*/ |
| 900 |
$this->log( debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, $in_depth_limit ), $category ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace |
| 901 |
} |
| 902 |
} |
| 903 |
|
| 904 |
/** |
| 905 |
* Data required from quarantine on scan item. |
| 906 |
* |
| 907 |
* @param Scan_Item $scan_item Scan item model object. |
| 908 |
* |
| 909 |
* @return array Array of data required from quarantine on scan item. |
| 910 |
*/ |
| 911 |
public function scan_item_data( Scan_Item $scan_item ): array { |
| 912 |
return array( |
| 913 |
'is_quarantined' => $this->is_quarantined( $scan_item ), |
| 914 |
'is_quarantinable' => $this->is_quarantinable( $scan_item->raw_data['file'] ), |
| 915 |
); |
| 916 |
} |
| 917 |
|
| 918 |
/** |
| 919 |
* Array of quarantined files, and it's source file details for HUB widget listing. |
| 920 |
* |
| 921 |
* @return array Quarantined files details. |
| 922 |
*/ |
| 923 |
public function hub_list(): array { |
| 924 |
$model_list = $this->quarantine_model->hub_list(); |
| 925 |
|
| 926 |
$hub_api_list = array(); |
| 927 |
|
| 928 |
foreach ( $model_list as $quarantine_item ) { |
| 929 |
$id = $quarantine_item['id']; |
| 930 |
$file_name = $quarantine_item['file_original_name'] . '.' . $quarantine_item['file_extension']; |
| 931 |
$quarantined_time = strtotime( $quarantine_item['quarantined_time'] ); |
| 932 |
$quarantined_path = $this->get_quarantined_file_path( $quarantine_item['quarantined_path'] ); |
| 933 |
$source_path = $quarantine_item['source_path']; |
| 934 |
|
| 935 |
array_push( |
| 936 |
$hub_api_list, |
| 937 |
compact( |
| 938 |
'id', |
| 939 |
'file_name', |
| 940 |
'quarantined_time', |
| 941 |
'quarantined_path', |
| 942 |
'source_path' |
| 943 |
) |
| 944 |
); |
| 945 |
} |
| 946 |
|
| 947 |
return $hub_api_list; |
| 948 |
} |
| 949 |
|
| 950 |
/** |
| 951 |
* Check the quarantine directory is forbidden. |
| 952 |
* When user visits the URL of the quarantine directory it should not be available. |
| 953 |
* This function returns false if we get the 200 status code and true for any other status codes. |
| 954 |
* |
| 955 |
* @return bool |
| 956 |
*/ |
| 957 |
public function is_quarantine_directory_url_forbidden(): bool { |
| 958 |
$response = wp_remote_head( |
| 959 |
$this->quarantine_directory_url(), |
| 960 |
array( 'timeout' => 5 ) |
| 961 |
); |
| 962 |
|
| 963 |
$is_200 = 200 === (int) wp_remote_retrieve_response_code( $response ); |
| 964 |
|
| 965 |
return ! $is_200; |
| 966 |
} |
| 967 |
} |
| 968 |
|