| 1 |
<?php |
| 2 |
|
| 3 |
namespace WP_Defender\Behavior\Scan_Item; |
| 4 |
|
| 5 |
use Calotes\Component\Behavior; |
| 6 |
use WP_Defender\Component\Error_Code; |
| 7 |
use WP_Defender\Model\Scan; |
| 8 |
use WP_Defender\Model\Scan_Item; |
| 9 |
use WP_Defender\Traits\Formats; |
| 10 |
use WP_Defender\Traits\IO; |
| 11 |
use WP_Defender\Traits\Plugin; |
| 12 |
use WP_Error; |
| 13 |
|
| 14 |
class Plugin_Integrity extends Behavior { |
| 15 |
use Formats, IO, Plugin; |
| 16 |
|
| 17 |
public const URL_PLUGIN_VCS = 'https://plugins.svn.wordpress.org/%s/tags/%s/%s'; |
| 18 |
|
| 19 |
/** |
| 20 |
* Trunk URL. |
| 21 |
* |
| 22 |
* @var string Trunk path of the plugin. |
| 23 |
*/ |
| 24 |
public const URL_PLUGIN_VCS_TRUNK = 'https://plugins.svn.wordpress.org/%s/trunk/%s'; |
| 25 |
|
| 26 |
/** |
| 27 |
* Return general data so we can output on frontend. |
| 28 |
* |
| 29 |
* @return array |
| 30 |
*/ |
| 31 |
public function to_array() { |
| 32 |
$data = $this->owner->raw_data; |
| 33 |
$file = $data['file']; |
| 34 |
$file_created_at = @filemtime( $file ); |
| 35 |
if ( $file_created_at ) { |
| 36 |
$file_created_at = $this->format_date_time( $file_created_at ); |
| 37 |
} else { |
| 38 |
$file_created_at = 'n/a'; |
| 39 |
} |
| 40 |
$file_size = @filesize( $file ); |
| 41 |
if ( ! $file_size ) { |
| 42 |
$file_size = 'n/a'; |
| 43 |
} else { |
| 44 |
$file_size = $this->format_bytes_into_readable( $file_size ); |
| 45 |
} |
| 46 |
|
| 47 |
return array( |
| 48 |
'id' => $this->owner->id, |
| 49 |
'type' => Scan_Item::TYPE_PLUGIN_CHECK, |
| 50 |
'file_name' => pathinfo( $file, PATHINFO_BASENAME ), |
| 51 |
'full_path' => $file, |
| 52 |
'date_added' => $file_created_at, |
| 53 |
'size' => $file_size, |
| 54 |
'scenario' => $data['type'], |
| 55 |
'short_desc' => $this->get_short_description(), |
| 56 |
); |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Get plugin data. |
| 61 |
* |
| 62 |
* @return array |
| 63 |
*/ |
| 64 |
private function find_plugin_by_slug( $slug ) { |
| 65 |
foreach ( $this->get_plugins() as $file => $data ) { |
| 66 |
// Comparison is faster, but the vast majority of plugins installed are not single file plugins. |
| 67 |
if ( $file === $slug || 0 === strpos( $file, $slug . '/' ) ) { |
| 68 |
return $data; |
| 69 |
} |
| 70 |
} |
| 71 |
|
| 72 |
return array(); |
| 73 |
} |
| 74 |
|
| 75 |
/** |
| 76 |
* We will get the origin code by looking into svn repo. |
| 77 |
* |
| 78 |
* @return false|string|\WP_Error |
| 79 |
*/ |
| 80 |
private function get_origin_code() { |
| 81 |
$data = $this->owner->raw_data; |
| 82 |
$file = wp_normalize_path( $data['file'] ); |
| 83 |
$ds = DIRECTORY_SEPARATOR; |
| 84 |
$relative_path = str_replace( wp_normalize_path( WP_PLUGIN_DIR ) . $ds, '', $file ); |
| 85 |
if ( empty( $relative_path ) ) { |
| 86 |
return new WP_Error( 'defender_broken_file_path', __( 'Failed relative file path.', 'wpdef' ) ); |
| 87 |
} |
| 88 |
|
| 89 |
if ( false === strpos( $relative_path, '/' ) ) { |
| 90 |
// Todo: get correct hashes for single-file plugins. Separate case for 'Hello Dolly'. |
| 91 |
$slug_and_path = 'hello.php' === $relative_path ? 'hello-dolly' : $relative_path; |
| 92 |
$path_data = array( $slug_and_path, $slug_and_path ); |
| 93 |
} else { |
| 94 |
$path_data = explode( $ds, $relative_path, 2 ); |
| 95 |
} |
| 96 |
if ( ! empty( $path_data ) ) { |
| 97 |
$plugin_slug = $path_data[0]; |
| 98 |
$file_path = $path_data[1]; |
| 99 |
} else { |
| 100 |
return new WP_Error( 'defender_broken_file_path', __( 'Broken file path.', 'wpdef' ) ); |
| 101 |
} |
| 102 |
|
| 103 |
$plugin_data = $this->find_plugin_by_slug( $plugin_slug ); |
| 104 |
if ( empty( $plugin_data ) ) { |
| 105 |
return new WP_Error( 'defender_broken_file_path', __( 'Empty plugin data.', 'wpdef' ) ); |
| 106 |
} |
| 107 |
|
| 108 |
// Get original from wp.org e.g. https://plugins.svn.wordpress.org/hello-dolly/tags/1.6/. |
| 109 |
$source_file_url = sprintf( |
| 110 |
self::URL_PLUGIN_VCS, |
| 111 |
$plugin_slug, |
| 112 |
$plugin_data['Version'], |
| 113 |
$file_path |
| 114 |
); |
| 115 |
// If file doesn't exist then get the latest stable file as a fallback. |
| 116 |
if ( ! $this->is_origin_file_exists( $source_file_url ) ) { |
| 117 |
$source_file_url = sprintf( |
| 118 |
self::URL_PLUGIN_VCS_TRUNK, |
| 119 |
$plugin_slug, |
| 120 |
$file_path |
| 121 |
); |
| 122 |
} |
| 123 |
|
| 124 |
if ( ! function_exists( 'download_url' ) ) { |
| 125 |
require_once ABSPATH . 'wp-admin' . $ds . 'includes' . $ds . 'file.php'; |
| 126 |
} |
| 127 |
$tmp = download_url( $source_file_url ); |
| 128 |
if ( is_wp_error( $tmp ) ) { |
| 129 |
return $tmp; |
| 130 |
} |
| 131 |
$content = file_get_contents( $tmp ); |
| 132 |
@unlink( $tmp ); |
| 133 |
|
| 134 |
return $content; |
| 135 |
} |
| 136 |
|
| 137 |
/** |
| 138 |
* Restore the file with its origin content. |
| 139 |
* |
| 140 |
* @return void|array |
| 141 |
*/ |
| 142 |
public function resolve() { |
| 143 |
$data = $this->owner->raw_data; |
| 144 |
if ( 'modified' !== $data['type'] ) { |
| 145 |
// Should not be here unless case changed. |
| 146 |
return; |
| 147 |
} |
| 148 |
|
| 149 |
$origin = $this->get_origin_code(); |
| 150 |
if ( false === $origin || is_wp_error( $origin ) ) { |
| 151 |
return; |
| 152 |
} |
| 153 |
|
| 154 |
$path = $data['file']; |
| 155 |
$ret = @file_put_contents( $path, $origin );// phpcs:ignore |
| 156 |
if ( $ret ) { |
| 157 |
$scan = Scan::get_last(); |
| 158 |
$scan->remove_issue( $this->owner->id ); |
| 159 |
$this->log( sprintf( '%s is deleted', $path ), 'scan.log' ); |
| 160 |
|
| 161 |
return array( |
| 162 |
'message' => __( 'This item has been resolved.', 'wpdef' ), |
| 163 |
); |
| 164 |
} else { |
| 165 |
return new WP_Error( |
| 166 |
'defender_permissions_denied', |
| 167 |
__( 'Permissions Denied. Defender does not have the needed permissions to edit the file. Please change file permissions to 640 or contact your hosting provider so they could change them for you.', 'wpdef' ) |
| 168 |
); |
| 169 |
} |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* @return array |
| 174 |
*/ |
| 175 |
public function ignore() { |
| 176 |
$scan = Scan::get_last(); |
| 177 |
$scan->ignore_issue( $this->owner->id ); |
| 178 |
|
| 179 |
return array( |
| 180 |
'message' => __( 'The suspicious file has been successfully ignored.', 'wpdef' ), |
| 181 |
); |
| 182 |
} |
| 183 |
|
| 184 |
/** |
| 185 |
* @return array |
| 186 |
*/ |
| 187 |
public function unignore() { |
| 188 |
$scan = Scan::get_last(); |
| 189 |
$scan->unignore_issue( $this->owner->id ); |
| 190 |
|
| 191 |
return array( |
| 192 |
'message' => __( 'The suspicious file has been successfully restored.', 'wpdef' ), |
| 193 |
); |
| 194 |
} |
| 195 |
|
| 196 |
/** |
| 197 |
* Delete the file or whole folder. |
| 198 |
* Todo: check it because the option don't have 'unversion' & 'dir' types. |
| 199 |
* |
| 200 |
* @return array|\WP_Error |
| 201 |
*/ |
| 202 |
public function delete() { |
| 203 |
$data = $this->owner->raw_data; |
| 204 |
$scan = Scan::get_last(); |
| 205 |
if ( 'unversion' === $data['type'] && unlink( $data['file'] ) ) { |
| 206 |
$scan->remove_issue( $this->owner->id ); |
| 207 |
$this->log( sprintf( '%s is deleted', $data['file'] ), 'scan.log' ); |
| 208 |
|
| 209 |
return array( |
| 210 |
'message' => __( 'This item has been permanently removed', 'wpdef' ), |
| 211 |
); |
| 212 |
} elseif ( 'dir' === $data['type'] && $this->delete_dir( $data['file'] ) ) { |
| 213 |
$scan->remove_issue( $this->owner->id ); |
| 214 |
$this->log( sprintf( '%s is deleted', $data['file'] ), 'scan.log' ); |
| 215 |
|
| 216 |
return array( |
| 217 |
'message' => __( 'This item has been permanently removed', 'wpdef' ), |
| 218 |
); |
| 219 |
} |
| 220 |
|
| 221 |
return new \WP_Error( Error_Code::NOT_WRITEABLE, __( 'Defender doesn\'t have enough permission to remove this file', 'wpdef' ) ); |
| 222 |
} |
| 223 |
|
| 224 |
/** |
| 225 |
* Return the source code depending on the type of the issue. |
| 226 |
* |
| 227 |
* @return array |
| 228 |
*/ |
| 229 |
public function pull_src() { |
| 230 |
$data = $this->owner->raw_data; |
| 231 |
if ( ! file_exists( $data['file'] ) && ! is_dir( $data['file'] ) ) { |
| 232 |
return array( |
| 233 |
'code' => '', |
| 234 |
'origin' => '', |
| 235 |
); |
| 236 |
} |
| 237 |
switch ( $data['type'] ) { |
| 238 |
case 'unversion': |
| 239 |
return array( |
| 240 |
'code' => file_get_contents( $data['file'] ), |
| 241 |
); |
| 242 |
case 'dir': |
| 243 |
$dir_tree = new File( $data['file'], true, true, array(), array(), false ); |
| 244 |
|
| 245 |
return array( |
| 246 |
'code' => implode( PHP_EOL, $dir_tree->get_dir_tree() ), |
| 247 |
); |
| 248 |
case 'modified': |
| 249 |
default: |
| 250 |
return array( |
| 251 |
'code' => file_get_contents( $data['file'] ), |
| 252 |
'origin' => $this->get_origin_code(), |
| 253 |
); |
| 254 |
} |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* Todo: check it because the option don't have 'unversion' & 'dir' types. |
| 259 |
* |
| 260 |
* @return string |
| 261 |
*/ |
| 262 |
private function get_short_description() { |
| 263 |
$data = $this->owner->raw_data; |
| 264 |
if ( 'unversion' === $data['type'] ) { |
| 265 |
return esc_html__( 'Unknown file in the WordPress plugin', 'wpdef' ); |
| 266 |
} elseif ( 'dir' === $data['type'] ) { |
| 267 |
return esc_html__( 'This directory does not belong to the WordPress plugin', 'wpdef' ); |
| 268 |
} elseif ( 'modified' === $data['type'] ) { |
| 269 |
return esc_html__( 'This plugin file appears modified', 'wpdef' ); |
| 270 |
} |
| 271 |
} |
| 272 |
|
| 273 |
/** |
| 274 |
* Check file exists at wp.org svn. |
| 275 |
* |
| 276 |
* @param string $url URL of the file. |
| 277 |
* |
| 278 |
* @return boolean Return true for file exists else false. |
| 279 |
*/ |
| 280 |
private function is_origin_file_exists( $url ) { |
| 281 |
$result = wp_remote_head( $url ); |
| 282 |
|
| 283 |
if ( ! is_wp_error( $result ) ) { |
| 284 |
$http_status_code = wp_remote_retrieve_response_code( $result ); |
| 285 |
|
| 286 |
return 200 === $http_status_code; |
| 287 |
} |
| 288 |
|
| 289 |
return false; |
| 290 |
} |
| 291 |
} |
| 292 |
|