PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.3.0
Defender Security – Malware Scanner, Login Security & Firewall v6.3.0
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
← All changes | src/model/class-scan.php +439 -177 5.3.1 → 6.3.0 View file →
@@ -16,8 +16,9 @@
16 16 use WP_Defender\Traits\Formats;
17 17 use WP_Defender\Component\Error_Code;
18 18 use WP_Defender\Behavior\Scan_Item\Core_Integrity;
19 19 use WP_Defender\Behavior\Scan_Item\Plugin_Integrity;
20 +use WP_Defender\Behavior\Scan_Item\Abandoned_Result;
20 21
21 22 /**
22 23 * Model for scan table.
23 24 */
@@ -27,9 +28,9 @@
27 28 use Formats;
28 29
29 30 public const STATUS_INIT = 'init', STATUS_ERROR = 'error', STATUS_FINISH = 'finish';
30 31 // Default state.
31 - public const STEP_GATHER_INFO = 'gather_info';
32 + public const STEP_GATHER_INFO = 'gather_info', STEP_ABANDONED_PLUGIN_CHECK = 'abandoned_plugin_check';
32 33 public const STEP_CHECK_CORE = 'core_integrity_check', STEP_CHECK_PLUGIN = 'plugin_integrity_check';
33 34 public const STEP_VULN_CHECK = 'vuln_check', STEP_SUSPICIOUS_CHECK = 'suspicious_check';
34 35 public const IGNORE_INDEXER = 'defender_scan_ignore_index';
35 36
@@ -40,8 +41,14 @@
40 41 */
41 42 protected $table = 'defender_scan';
42 43
43 44 /**
45 + * Active scan type across page loads.
46 + */
47 + public const OPTION_SCAN_TYPE = 'wpdef_active_scan_type';
48 + public const OPTION_STATUS_MESSAGES = 'wpdef_scan_status_messages';
49 +
50 + /**
44 51 * Any valid relative Date and Time formats.
45 52 *
46 53 * @link https://www.php.net/manual/en/datetime.formats.relative.php
47 54 * @since 2.6.1
@@ -75,8 +82,9 @@
75 82 * - plugin_integrity_check
76 83 * - vuln_check
77 84 * - suspicious_check
78 85 * - idle
86 + * - abandoned_plugin_check
79 87 *
80 88 * @var string
81 89 * @defender_property
82 90 */
@@ -149,12 +157,26 @@
149 157 * - 'count_core' (int): Number of core integrity issues.
150 158 * - 'count_plugin' (int): Number of plugin check issues.
151 159 * - 'count_malware' (int): Number of suspicious/malware issues.
152 160 * - 'count_vuln' (int): Number of vulnerability issues.
161 + * - 'count_outdated_plugin' (int): Number of outdated plugin issues.
162 + * - 'count_closed_plugin' (int): Number of closed plugin issues.
163 + * @param string|null $scenario Pagination scenario: 'issue', 'ignored', or null (paginate both).
153 164 */
154 - public function prepare_issues( $per_page = null, $paged = null, $type = null ): array {
155 - $ignored_models = $this->get_issues( $type, Scan_Item::STATUS_IGNORE, $per_page, $paged );
156 - $active_models = $this->get_issues( $type, Scan_Item::STATUS_ACTIVE, $per_page, $paged );
165 + public function prepare_issues( $per_page = null, $paged = null, $type = null, $scenario = null ): array {
166 + if ( 'issue' === $scenario ) {
167 + // Paginate active issues only; ignored items are not needed for this tab.
168 + $ignored_models = array();
169 + $active_models = $this->get_issues( $type, Scan_Item::STATUS_ACTIVE, $per_page, $paged );
170 + } elseif ( 'ignored' === $scenario ) {
171 + // Paginate ignored items only; active issues are not needed for this tab.
172 + $ignored_models = $this->get_issues( $type, Scan_Item::STATUS_IGNORE, $per_page, $paged );
173 + $active_models = array();
174 + } else {
175 + // Default (initial load): paginate both statuses with the same page.
176 + $ignored_models = $this->get_issues( $type, Scan_Item::STATUS_IGNORE, $per_page, $paged );
177 + $active_models = $this->get_issues( $type, Scan_Item::STATUS_ACTIVE, $per_page, $paged );
178 + }
157 179
158 180 $issues = array();
159 181 $ignored = array();
160 182 $count_total = count( $active_models );
@@ -161,23 +183,20 @@
161 183 $count_issues_filtered = 0;
162 184
163 185 $scan_item_group_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_ACTIVE );
164 186
165 - $count_issues = ! empty( $scan_item_group_total['all'] ) ?
166 - $scan_item_group_total['all'] : 0;
167 - $count_core = ! empty( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
168 - $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
169 - $count_plugin = ! empty( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
170 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
171 - $count_malware = ! empty( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
172 - $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
173 - $count_vuln = ! empty( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
174 - $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] : 0;
187 + $count_issues = $scan_item_group_total['all'] ?? 0;
188 + $count_core = $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ?? 0;
189 + $count_plugin = $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ?? 0;
190 + $count_malware = $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ?? 0;
191 + $count_vuln = $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ?? 0;
192 + // New counts since v5.5.0.
193 + $count_outdated_plugin = $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ?? 0;
194 + $count_closed_plugin = $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ?? 0;
175 195
176 196 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
177 197
178 - $count_ignored = ! empty( $scan_item_ignore_total['all'] ) ?
179 - $scan_item_ignore_total['all'] : 0;
198 + $count_ignored = $scan_item_ignore_total['all'] ?? 0;
180 199
181 200 foreach ( $ignored_models as $model ) {
182 201 $ignored[] = $model->to_array();
183 202 }
@@ -185,10 +204,12 @@
185 204 $issues[] = $active_model->to_array();
186 205
187 206 // We will now count all issues again by type filter for pagination usage.
188 207 if ( null !== $type && 'all' !== $type ) {
189 - if ( $type === $active_model->type ) {
208 + if ( is_array( $type ) && in_array( $active_model->type, $type, true ) ) {
190 209 ++$count_issues_filtered;
210 + } elseif ( $type === $active_model->type ) {
211 + ++$count_issues_filtered;
191 212 }
192 213 } else {
193 214 ++$count_issues_filtered;
194 215 }
@@ -204,23 +225,37 @@
204 225 'count_core' => $count_core,
205 226 'count_plugin' => $count_plugin,
206 227 'count_malware' => $count_malware,
207 228 'count_vuln' => $count_vuln,
229 + 'count_outdated_plugin' => $count_outdated_plugin,
230 + 'count_closed_plugin' => $count_closed_plugin,
208 231 );
209 232 }
210 233
211 234 /**
235 + * Get abandoned plugin types.
236 + *
237 + * @return array
238 + */
239 + public static function get_abandoned_types(): array {
240 + return array(
241 + Scan_Item::TYPE_PLUGIN_CLOSED,
242 + Scan_Item::TYPE_PLUGIN_OUTDATED,
243 + );
244 + }
245 +
246 + /**
212 247 * Retrieves scan issues based on provided filters.
213 248 * This method fetches scan items related to the current object's ID,
214 249 * filtered by type, status, and pagination parameters.
215 250 * The retrieved items are then attached with relevant behaviors based on their type.
216 251 *
217 - * @param string|null $type Optional. The type of scan issue to filter by.
218 - * Accepts 'vulnerability', 'integrity', 'plugin_check', or 'suspicious'. Default null.
219 - * @param string|null $status Optional. The status of the scan issue to filter by.
220 - * Accepts 'ignore' or 'active'. Default null.
221 - * @param int|null $per_page Optional. The number of items to retrieve per page. Default null.
222 - * @param int|null $paged Optional. The page number of items to retrieve. Default null.
252 + * @param string|array|null $type Optional. The type of scan issue to filter by.
253 + * Accepts 'vulnerability', 'integrity', 'plugin_check', or 'suspicious'. Default null.
254 + * @param string|null $status Optional. The status of the scan issue to filter by.
255 + * Accepts 'ignore' or 'active'. Default null.
256 + * @param int|null $per_page Optional. The number of items to retrieve per page. Default null.
257 + * @param int|null $paged Optional. The page number of items to retrieve. Default null.
223 258 *
224 259 * @return array An array of scan issue models with attached behaviors.
225 260 */
226 261 public function get_issues( $type = null, $status = null, $per_page = null, $paged = null ) {
@@ -225,48 +260,52 @@
225 260 */
226 261 public function get_issues( $type = null, $status = null, $per_page = null, $paged = null ) {
227 262 $orm = self::get_orm();
228 263 $builder = $orm->get_repository( Scan_Item::class )
229 - ->where( 'parent_id', $this->id );
264 + ->where( 'parent_id', $this->id );
230 265
231 - if (
232 - ! is_null( $type )
233 - && in_array(
234 - $type,
235 - array(
236 - Scan_Item::TYPE_VULNERABILITY,
237 - Scan_Item::TYPE_INTEGRITY,
238 - Scan_Item::TYPE_PLUGIN_CHECK,
239 - Scan_Item::TYPE_SUSPICIOUS,
240 - ),
241 - true
242 - )
243 - ) {
244 - $builder->where( 'type', $type );
266 + $valid_types = Scan_Item::get_all_scan_types();
267 + if ( null !== $type ) {
268 + if ( is_array( $type ) ) {
269 + $filtered_types = array_intersect( $type, $valid_types );
270 + if ( array() !== $filtered_types ) {
271 + $builder->where( 'type', 'IN', $filtered_types );
272 + }
273 + } elseif ( in_array( $type, $valid_types, true ) ) {
274 + $builder->where( 'type', $type );
275 + } elseif ( 'all' === $type ) {
276 + $builder->where( 'type', 'IN', $valid_types );
277 + }
278 + } else {
279 + // Include all possible types.
280 + $builder->where( 'type', 'IN', $valid_types );
245 281 }
246 - if (
247 - ! is_null( $status )
248 - && in_array( $status, array( Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_ACTIVE ), true )
249 - ) {
250 - $builder->where( 'status', $status );
282 +
283 + if ( null !== $status ) {
284 + if ( in_array( $status, Scan_Item::get_all_scan_statuses(), true ) ) {
285 + $builder->where( 'status', $status );
286 + }
251 287 }
252 - if ( ! is_null( $per_page ) && ! is_null( $paged ) ) {
253 - $limit = ( ( $paged - 1 ) * $per_page ) . ',' . $per_page;
254 - $builder->limit( $limit );
288 +
289 + if ( is_int( $per_page ) && is_int( $paged ) && $per_page > 0 && $paged > 0 ) {
290 + $offset = ( $paged - 1 ) * $per_page;
291 + $builder->limit( $per_page, $offset );
255 292 }
293 +
256 294 $models = $builder->get();
257 - foreach ( $models as $key => $model ) {
258 - switch ( $model->type ) {
259 - case Scan_Item::TYPE_INTEGRITY:
260 - $model->attach_behavior( Core_Integrity::class, Core_Integrity::class );
261 - break;
262 - case Scan_Item::TYPE_PLUGIN_CHECK:
263 - $model->attach_behavior( Plugin_Integrity::class, Plugin_Integrity::class );
264 - break;
265 - default:
266 - break;
295 +
296 + static $behavior_map = array(
297 + Scan_Item::TYPE_INTEGRITY => Core_Integrity::class,
298 + Scan_Item::TYPE_PLUGIN_CHECK => Plugin_Integrity::class,
299 + Scan_Item::TYPE_PLUGIN_CLOSED => Abandoned_Result::class,
300 + Scan_Item::TYPE_PLUGIN_OUTDATED => Abandoned_Result::class,
301 + );
302 +
303 + foreach ( $models as $model ) {
304 + if ( isset( $behavior_map[ $model->type ] ) ) {
305 + $behavior_class = $behavior_map[ $model->type ];
306 + $model->attach_behavior( $behavior_class, $behavior_class );
267 307 }
268 - $models[ $key ] = $model;
269 308 }
270 309
271 310 return $models;
272 311 }
@@ -273,13 +312,11 @@
273 312
274 313 /**
275 314 * Counts the number of Scan_Item models that match the given type and status.
276 315 *
277 - * @param string|null $type The type of Scan_Item to count. Must be one of the following:
278 - * Scan_Item::TYPE_VULNERABILITY, Scan_Item::TYPE_INTEGRITY,
279 - * Scan_Item::TYPE_PLUGIN_CHECK, Scan_Item::TYPE_SUSPICIOUS.
280 - * @param string|null $status The status of the Scan_Item to count. Must be one of the following:
281 - * Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_ACTIVE.
316 + * @param string|array|null $type The type(s) of Scan_Item to count.
317 + * @param string|null $status The status of the Scan_Item to count. Must be one of the following:
318 + * Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_ACTIVE.
282 319 *
283 320 * @return mixed The number of matching Scan_Item models.
284 321 */
285 322 public function count( $type = null, $status = null ) {
@@ -285,26 +322,27 @@
285 322 public function count( $type = null, $status = null ) {
286 323 $orm = self::get_orm();
287 324 $builder = $orm->get_repository( Scan_Item::class )->where( 'parent_id', $this->id );
288 325
289 - if (
326 + $valid_types = Scan_Item::get_all_scan_types();
327 +
328 + if ( is_array( $type ) ) {
329 + $filtered_types = array_intersect( $type, $valid_types );
330 + if ( array() !== $filtered_types ) {
331 + $builder->where( 'type', 'IN', $filtered_types );
332 + }
333 + } elseif (
290 334 ! is_null( $type )
291 - && in_array(
292 - $type,
293 - array(
294 - Scan_Item::TYPE_VULNERABILITY,
295 - Scan_Item::TYPE_INTEGRITY,
296 - Scan_Item::TYPE_PLUGIN_CHECK,
297 - Scan_Item::TYPE_SUSPICIOUS,
298 - ),
299 - true
300 - )
335 + && in_array( $type, $valid_types, true )
301 336 ) {
302 337 $builder->where( 'type', $type );
338 + } elseif ( is_null( $type ) || 'all' === $type ) {
339 + $builder->where( 'type', 'IN', $valid_types );
303 340 }
341 +
304 342 if (
305 343 ! is_null( $status )
306 - && in_array( $status, array( Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_ACTIVE ), true )
344 + && in_array( $status, Scan_Item::get_all_scan_statuses(), true )
307 345 ) {
308 346 $builder->where( 'status', $status );
309 347 }
310 348
@@ -310,31 +348,18 @@
310 348
311 349 return $builder->count();
312 350 }
313 351
352 +
314 353 /**
315 354 * Allow a specific issue by updating its status and removing it from the global ignore indexer.
316 355 *
317 356 * @param int $id The ID of the issue to Allow.
318 357 *
319 - * @return bool|void Returns false if the issue does not exist, otherwise void.
358 + * @return bool
320 359 */
321 - public function unignore_issue( $id ) {
322 - $issue = $this->get_issue( $id );
323 - if ( ! is_object( $issue ) ) {
324 - return false;
325 - }
326 - $issue->status = Scan_Item::STATUS_ACTIVE;
327 - $issue->save();
328 -
329 - $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
330 - $data = $issue->raw_data;
331 - if ( isset( $data['file'] ) ) {
332 - unset( $ignore_lists[ array_search( $data['file'], $ignore_lists, true ) ] );
333 - } elseif ( isset( $data['slug'] ) ) {
334 - unset( $ignore_lists[ array_search( $data['slug'], $ignore_lists, true ) ] );
335 - }
336 - $this->update_ignore_list( $ignore_lists );
360 + public function unignore_issue( $id ): bool {
361 + return $this->update_issue_status( (int) $id, Scan_Item::STATUS_ACTIVE, Scan_Item::STATUS_ACTIVE );
337 362 }
338 363
339 364 /**
340 365 * Check if a slug is ignored, we use a global indexer, so we can check while
@@ -350,32 +375,16 @@
350 375 return in_array( $slug, $ignore_lists, true );
351 376 }
352 377
353 378 /**
354 - * Ignore a specific issue by updating its status and adding it to the global ignore indexer.
379 + * Ignore a specific issue by updating its status and adding it to the global ignored indexer.
355 380 *
356 381 * @param int $id The ID of the issue to ignore.
357 382 *
358 - * @return bool|void Returns false if the issue does not exist, otherwise void.
383 + * @return bool
359 384 */
360 - public function ignore_issue( $id ) {
361 - $issue = $this->get_issue( $id );
362 - if ( ! is_object( $issue ) ) {
363 - return false;
364 - }
365 -
366 - $issue->status = Scan_Item::STATUS_IGNORE;
367 - $issue->save();
368 -
369 - // Add this into global ignore index.
370 - $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
371 - $data = $issue->raw_data;
372 - if ( isset( $data['file'] ) ) {
373 - $ignore_lists[] = $data['file'];
374 - } elseif ( isset( $data['slug'] ) ) {
375 - $ignore_lists[] = $data['slug'];
376 - }
377 - $this->update_ignore_list( $ignore_lists );
385 + public function ignore_issue( $id ): bool {
386 + return $this->update_issue_status( (int) $id, Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_IGNORE );
378 387 }
379 388
380 389 /**
381 390 * Retrieves a Scan_Item object based on the given ID.
@@ -390,17 +399,18 @@
390 399 ->where( 'id', $id )
391 400 ->first();
392 401
393 402 if ( is_object( $model ) ) {
394 - switch ( $model->type ) {
395 - case Scan_Item::TYPE_INTEGRITY:
396 - $model->attach_behavior( Core_Integrity::class, Core_Integrity::class );
397 - break;
398 - case Scan_Item::TYPE_PLUGIN_CHECK:
399 - $model->attach_behavior( Plugin_Integrity::class, Plugin_Integrity::class );
400 - break;
401 - default:
402 - break;
403 + static $behavior_map = array(
404 + Scan_Item::TYPE_INTEGRITY => Core_Integrity::class,
405 + Scan_Item::TYPE_PLUGIN_CHECK => Plugin_Integrity::class,
406 + Scan_Item::TYPE_PLUGIN_CLOSED => Abandoned_Result::class,
407 + Scan_Item::TYPE_PLUGIN_OUTDATED => Abandoned_Result::class,
408 + );
409 +
410 + if ( isset( $behavior_map[ $model->type ] ) ) {
411 + $behavior_class = $behavior_map[ $model->type ];
412 + $model->attach_behavior( $behavior_class, $behavior_class );
403 413 }
404 414 }
405 415
406 416 return $model;
@@ -416,8 +426,21 @@
416 426 $orm->get_repository( Scan_Item::class )->delete( array( 'id' => $id ) );
417 427 }
418 428
419 429 /**
430 + * Get all scan types where the target entity is the whole folder.
431 + *
432 + * @return array
433 + */
434 + private function get_scan_types_for_whole_folder(): array {
435 + return array(
436 + Scan_Item::TYPE_VULNERABILITY,
437 + Scan_Item::TYPE_PLUGIN_CLOSED,
438 + Scan_Item::TYPE_PLUGIN_OUTDATED,
439 + );
440 + }
441 +
442 + /**
420 443 * Remove other Scan issue(-s) for the same file.
421 444 *
422 445 * @param string $path The path to file.
423 446 * @param string $type The type of scan issue.
@@ -424,22 +447,22 @@
424 447 *
425 448 * @return void
426 449 */
427 450 public function remove_related_issue_by( string $path, string $type ) {
428 - // No needs to separate check Scan_Item::TYPE_VULNERABILITY because we do not delete per file for that type.
429 451 $orm = self::get_orm();
430 452 $builder = $orm->get_repository( Scan_Item::class )
431 453 ->where( 'parent_id', $this->id );
454 + // No needs to separate check VULNERABILITY, PLUGIN_CLOSED and PLUGIN_OUTDATED because we do not delete per file for such types.
455 + $arr_excluded_types = $this->get_scan_types_for_whole_folder();
432 456 if ( '' !== $path ) {
433 - $builder->where( 'type', 'NOT IN', array( $type, Scan_Item::TYPE_VULNERABILITY ) );
434 - } else {
435 - $builder->where( 'type', 'NOT IN', array( Scan_Item::TYPE_VULNERABILITY ) );
457 + $arr_excluded_types[] = $type;
436 458 }
459 + $builder->where( 'type', 'NOT IN', $arr_excluded_types );
437 460 $models = $builder->get();
438 461
439 - if ( ! empty( $models ) ) {
462 + if ( is_array( $models ) && array() !== $models ) {
440 463 foreach ( $models as $model ) {
441 - if ( $model->raw_data['file'] === $path ) {
464 + if ( isset( $model->raw_data['file'] ) && $model->raw_data['file'] === $path ) {
442 465 $this->remove_issue( $model->id );
443 466 }
444 467 }
445 468 }
@@ -448,15 +471,16 @@
448 471 /**
449 472 * Converts the object to an array representation.
450 473 *
451 474 * @param int|null $per_page The number of items to retrieve per page. Default null.
452 - * @param int|null $paged The page number of items to retrieve. Default null.
453 - * @param string|null $type The type of scan issue to filter by. Default null.
475 + * @param int|null $paged The page number of items to retrieve. Default null.
476 + * @param string|null $type The type of scan issue to filter by. Default null.
477 + * @param string|null $scenario Pagination scenario ('issue', 'ignored', or null). Default null.
454 478 *
455 479 * @return array The array representation of the object.
456 480 */
457 - public function to_array( $per_page = null, $paged = null, $type = null ) {
458 - if ( ! in_array( $this->status, array( self::STATUS_ERROR, self::STATUS_FINISH, self::STATUS_IDLE ), true ) ) {
481 + public function to_array( $per_page = null, $paged = null, $type = null, $scenario = null ) {
482 + if ( ! in_array( $this->status, self::get_inactive_statuses(), true ) ) {
459 483
460 484 return array(
461 485 'status' => $this->status,
462 486 'status_text' => $this->get_status_text(),
@@ -472,51 +496,56 @@
472 496
473 497 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )
474 498 ->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
475 499
476 - $count_ignored = ! empty( $scan_item_ignore_total['all'] ) ?
477 - $scan_item_ignore_total['all'] : 0;
500 + $count_ignored = $scan_item_ignore_total['all'] ?? 0;
501 + $count_ignored_filtered = (int) $this->count( $type, Scan_Item::STATUS_IGNORE );
478 502
479 503 $total_issue_pages = 1;
480 504 $total_ignored_pages = 1;
481 - if ( ! is_null( $per_page ) && ( $total_count > $per_page ) ) {
482 - $data = $this->prepare_issues( $per_page, $paged, $type );
483 - if ( ! is_null( $paged ) ) {
484 - $total_issue_pages = ceil( $count_issues_filtered / $per_page );
485 - $total_ignored_pages = ceil( $count_ignored / $per_page );
486 - }
505 + if ( is_int( $per_page ) && $per_page > 0 ) {
506 + $total_issue_pages = max( 1, (int) ceil( $count_issues_filtered / $per_page ) );
507 + $total_ignored_pages = max( 1, (int) ceil( $count_ignored_filtered / $per_page ) );
508 + }
509 +
510 + // On the initial load both tabs are fetched, so either tab can require pagination.
511 + if ( 'ignored' === $scenario ) {
512 + $relevant_count = $count_ignored_filtered;
513 + } elseif ( 'issue' === $scenario ) {
514 + $relevant_count = $count_issues_filtered;
487 515 } else {
488 - $data = $this->prepare_issues( null, null, $type );
516 + $relevant_count = max( $count_issues_filtered, $count_ignored_filtered );
489 517 }
490 518
491 - $scan_item_group_total = wd_di()->get( Scan_Item::class )
492 - ->get_types_total( $this->id, Scan_Item::STATUS_ACTIVE );
519 + if ( is_int( $per_page ) && $per_page > 0 && $relevant_count > $per_page ) {
520 + $data = $this->prepare_issues( $per_page, $paged, $type, $scenario );
521 + } else {
522 + $data = $this->prepare_issues( null, null, $type, $scenario );
523 + }
493 524
494 - $count_issues = ! empty( $scan_item_group_total['all'] ) ?
495 - $scan_item_group_total['all'] : 0;
496 - $count_core = ! empty( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
497 - $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
498 - $count_plugin = ! empty( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
499 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
500 - $count_malware = ! empty( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
501 - $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
502 - $count_vuln = ! empty( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
503 - $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] : 0;
504 -
505 525 return array(
506 526 'status' => $this->status,
507 527 'issues_items' => $data['issues'],
508 528 'ignored_items' => $data['ignored'],
509 - 'last_scan' => $this->format_date_time( $this->date_start ),
529 + 'last_scan' => $this->date_start
530 + ? sprintf(
531 + /* translators: %s: human-readable time difference, e.g. "5 minutes" */
532 + __( '%s ago', 'defender-security' ),
533 + human_time_diff( strtotime( $this->date_start ) )
534 + )
535 + : '',
510 536 'count' => array(
511 - 'total' => is_array( $data['issues'] ) || $data['issues'] instanceof Countable ? count( $data['issues'] ) : 0,
512 - 'total_filtered' => $total_filtered,
513 - 'issues_total' => $count_issues,
514 - 'issues_total_filtered' => $count_issues_filtered,
515 - 'ignored_total' => $count_ignored,
516 - 'core' => $count_core + $count_plugin,
517 - 'content' => $count_malware,
518 - 'vuln' => $count_vuln,
537 + 'total' => is_array( $data['issues'] ) || $data['issues'] instanceof Countable ? count( $data['issues'] ) : 0,
538 + 'total_filtered' => $total_filtered,
539 + 'issues_total' => $data['count_issues'],
540 + 'issues_total_filtered' => $count_issues_filtered,
541 + 'ignored_total' => $count_ignored,
542 + 'ignored_total_filtered' => $count_ignored_filtered,
543 + 'core' => $data['count_core'] + $data['count_plugin'],
544 + 'content' => $data['count_malware'],
545 + 'vuln' => $data['count_vuln'],
546 + 'outdated_plugin' => $data['count_outdated_plugin'],
547 + 'closed_plugin' => $data['count_closed_plugin'],
519 548 ),
520 549 'paging' => array(
521 550 'issue' => array(
522 551 'paged' => $paged,
@@ -567,8 +596,9 @@
567 596 public function delete( $id = null ) {
568 597 if ( ! $this->is_positive_int( $id ) ) {
569 598 $id = $this->id;
570 599 }
600 + $this->clear_status_messages( $id );
571 601
572 602 // Delete all the related result items.
573 603 $orm = self::get_orm();
574 604
@@ -581,8 +611,91 @@
581 611 );
582 612 }
583 613
584 614 /**
615 + * Store a user-visible status transition for this scan.
616 + *
617 + * @param string $message Status transition message.
618 + */
619 + public function enqueue_status_message( $message ): void {
620 + $message = sanitize_text_field( wp_strip_all_tags( (string) $message ) );
621 + if ( '' === $message || ! $this->is_positive_int( $this->id ) ) {
622 + return;
623 + }
624 +
625 + $queues = get_site_option( self::OPTION_STATUS_MESSAGES, array() );
626 + $queues = is_array( $queues ) ? $queues : array();
627 + $key = (string) $this->id;
628 + $stored_queue = $queues[ $key ] ?? null;
629 + $queue = is_array( $stored_queue ) ? $stored_queue : array();
630 + if ( end( $queue ) !== $message ) {
631 + $queue[] = $message;
632 + $queues[ $key ] = $queue;
633 + update_site_option( self::OPTION_STATUS_MESSAGES, $queues );
634 + }
635 + }
636 +
637 + /**
638 + * Return queued status messages without consuming them.
639 + */
640 + public function get_status_messages(): array {
641 + $queues = get_site_option( self::OPTION_STATUS_MESSAGES, array() );
642 + $queue = is_array( $queues ) ? ( $queues[ (string) $this->id ] ?? array() ) : array();
643 +
644 + return is_array( $queue ) ? array_values( $queue ) : array();
645 + }
646 +
647 + /**
648 + * Return and remove queued status messages.
649 + */
650 + public function drain_status_messages(): array {
651 + $messages = $this->get_status_messages();
652 + $this->clear_status_messages();
653 +
654 + return $messages;
655 + }
656 +
657 + /**
658 + * Remove queued messages for one scan.
659 + *
660 + * @param int|null $id Table primary key ID.
661 + */
662 + public function clear_status_messages( $id = null ): void {
663 + $id = $this->is_positive_int( $id ) ? $id : $this->id;
664 + if ( ! $this->is_positive_int( $id ) ) {
665 + return;
666 + }
667 + $queues = get_site_option( self::OPTION_STATUS_MESSAGES, array() );
668 + if ( ! is_array( $queues ) || ! array_key_exists( (string) $id, $queues ) ) {
669 + return;
670 + }
671 + unset( $queues[ (string) $id ] );
672 + array() === $queues
673 + ? delete_site_option( self::OPTION_STATUS_MESSAGES )
674 + : update_site_option( self::OPTION_STATUS_MESSAGES, $queues );
675 + }
676 +
677 + /**
678 + * Remove status queues for all scans.
679 + */
680 + public static function clear_all_status_messages(): void {
681 + delete_site_option( self::OPTION_STATUS_MESSAGES );
682 + }
683 +
684 + /**
685 + * Get the inactive scan statuses.
686 + *
687 + * @return array
688 + */
689 + public static function get_inactive_statuses(): array {
690 + return array(
691 + self::STATUS_FINISH,
692 + self::STATUS_ERROR,
693 + self::STATUS_IDLE,
694 + );
695 + }
696 +
697 + /**
585 698 * Get the current active scan if any.
586 699 *
587 700 * @return self|null
588 701 */
@@ -589,9 +702,9 @@
589 702 public static function get_active() {
590 703 $orm = self::get_orm();
591 704
592 705 return $orm->get_repository( self::class )
593 - ->where( 'status', 'NOT IN', array( self::STATUS_FINISH, self::STATUS_ERROR, self::STATUS_IDLE ) )
706 + ->where( 'status', 'NOT IN', self::get_inactive_statuses() )
594 707 ->first();
595 708 }
596 709
597 710 /**
@@ -655,8 +768,70 @@
655 768 return $ret;
656 769 }
657 770
658 771 /**
772 + * Carry previously-ignored issues forward into this scan, without duplicating
773 + * any that were already re-detected as active in the current pass.
774 + *
775 + * @param array|string $type Scan item type(s) the issues belong to.
776 + * @param array $issues Scan_Item objects (status ignore) to carry forward.
777 + * @param string $key_field Field inside raw_data used to match issues, e.g. 'file' or 'slug'.
778 + */
779 + public function carry_forward_ignored_issues( $type, array $issues, string $key_field ) {
780 + if ( array() === $issues ) {
781 + return;
782 + }
783 +
784 + $active_by_key = array();
785 + foreach ( $this->get_issues( $type, Scan_Item::STATUS_ACTIVE ) as $active_item ) {
786 + $key = $active_item->raw_data[ $key_field ] ?? null;
787 + if ( null !== $key ) {
788 + $active_by_key[ $key ] = $active_item;
789 + }
790 + }
791 +
792 + foreach ( $issues as $issue ) {
793 + $key = $issue->raw_data[ $key_field ] ?? null;
794 + if ( null !== $key && isset( $active_by_key[ $key ] ) ) {
795 + // Already re-detected as active in this scan; keep it ignored, don't duplicate.
796 + $active_by_key[ $key ]->status = Scan_Item::STATUS_IGNORE;
797 + $active_by_key[ $key ]->save();
798 + continue;
799 + }
800 + $this->add_item( $issue->type, $issue->raw_data, Scan_Item::STATUS_IGNORE );
801 + }
802 + }
803 +
804 + /**
805 + * Persist the current scan type ('deep' or 'malware') in a site option.
806 + *
807 + * @param string $type Scan type identifier. Accepts 'deep' or 'malware'.
808 + * @return void
809 + */
810 + public static function set_scan_type( string $type ): void {
811 + update_site_option( self::OPTION_SCAN_TYPE, $type );
812 + }
813 +
814 + /**
815 + * Retrieve the persisted scan type. Returns 'malware' when nothing is stored.
816 + *
817 + * @return string
818 + */
819 + public static function get_scan_type(): string {
820 + return (string) get_site_option( self::OPTION_SCAN_TYPE, 'malware' );
821 + }
822 +
823 + /**
824 + * Delete the persisted scan type (called on scan cancel or after returning
825 + * the finished-scan response so the option does not linger).
826 + *
827 + * @return void
828 + */
829 + public static function clear_scan_type(): void {
830 + delete_site_option( self::OPTION_SCAN_TYPE );
831 + }
832 +
833 + /**
659 834 * Return current status as readable string.
660 835 *
661 836 * @return string
662 837 */
@@ -662,21 +837,23 @@
662 837 */
663 838 public function get_status_text() {
664 839 switch ( $this->status ) {
665 840 case self::STATUS_INIT:
666 - return esc_html__( 'Initializing...', 'defender-security' );
841 + return 'deep' === self::get_scan_type() ? wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) ) : wp_strip_all_tags( __( 'Initializing...', 'defender-security' ) );
667 842 case self::STEP_GATHER_INFO:
668 - return esc_html__( 'Gathering information...', 'defender-security' );
843 + return wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) );
669 844 case self::STEP_CHECK_CORE:
670 - return esc_html__( 'Analyzing WordPress Core...', 'defender-security' );
845 + return wp_strip_all_tags( __( 'Analyzing WordPress Core...', 'defender-security' ) );
671 846 case self::STEP_CHECK_PLUGIN:
672 - return esc_html__( 'Analyzing WordPress Plugins...', 'defender-security' );
847 + return wp_strip_all_tags( __( 'Analyzing WordPress Plugins...', 'defender-security' ) );
673 848 case self::STEP_VULN_CHECK:
674 - return esc_html__( 'Checking for any published vulnerabilities in your plugins and themes...', 'defender-security' );
849 + return wp_strip_all_tags( __( 'Checking for vulnerabilities in plugins and themes...', 'defender-security' ) );
675 850 case self::STEP_SUSPICIOUS_CHECK:
676 - return esc_html__( 'Analyzing WordPress Content...', 'defender-security' );
851 + return wp_strip_all_tags( __( 'Analyzing WordPress Content...', 'defender-security' ) );
852 + case self::STEP_ABANDONED_PLUGIN_CHECK:
853 + return wp_strip_all_tags( __( 'Checking for outdated & removed plugins...', 'defender-security' ) );
677 854 default:
678 - return esc_html__( 'The scan is running', 'defender-security' );
855 + return wp_strip_all_tags( __( 'Scanning...', 'defender-security' ) );
679 856 }
680 857 }
681 858
682 859 /**
@@ -690,9 +867,9 @@
690 867 public function calculate_percent( $task_percent, $pos = 1 ) {
691 868 $task_max = ( 0 !== $this->total_tasks ) ? ( 100 / $this->total_tasks ) : 0;
692 869 $task_base = $task_max * ( $pos - 1 );
693 870 $micro = $task_percent * $task_max / 100;
694 - $this->percent = round( $task_base + $micro, 2 );
871 + $this->percent = (int) round( $task_base + $micro, 2 );
695 872 if ( $this->percent > 100 ) {
696 873 $this->percent = 100;
697 874 }
698 875
@@ -726,17 +903,32 @@
726 903
727 904 /**
728 905 * Check if a slug is whitelisted.
729 906 *
907 + * File entries are matched only when the slug's base name equals the entry
908 + * (case-insensitive), so paths like "robots.txt.backdoor" or
909 + * "robots.txt/malware.php" are never falsely accepted.
910 + *
911 + * Directory entries are matched as complete path segments, preventing
912 + * substring bypasses like ".git-malware/payload.php" or "not.git/payload.php".
913 + *
730 914 * @param string $slug The path to file.
731 915 *
732 916 * @return bool
733 917 */
734 - public function is_issue_whitelisted( $slug ) {
735 - $whitelisted_files = $this->whitelisted_files();
736 - foreach ( $whitelisted_files as $file ) {
737 - if ( false !== stristr( $slug, $file ) ) {
738 - return true;
918 + public function is_issue_whitelisted( string $slug ): bool {
919 + $whitelisted_files = $this->whitelisted_files();
920 + $whitelisted_directories = array( '.well-known', '.idea', '.svn', '.git', '.quarantine', '.tmb', '.vscode' );
921 + $normalized_slug = str_replace( '\\', '/', $slug );
922 + $bounded_slug = '/' . trim( $normalized_slug, '/' ) . '/';
923 +
924 + foreach ( $whitelisted_files as $entry ) {
925 + if ( ! in_array( $entry, $whitelisted_directories, true ) ) {
926 + if ( 0 === strcasecmp( basename( $normalized_slug ), $entry ) ) {
927 + return true;
928 + }
929 + } elseif ( false !== stripos( $bounded_slug, '/' . $entry . '/' ) ) {
930 + return true;
739 931 }
740 932 }
741 933
742 934 return false;
@@ -748,9 +940,9 @@
748 940 * @param array $ignore_lists Items to be added to the ignore list.
749 941 */
750 942 public function update_ignore_list( $ignore_lists ) {
751 943 $ignore_lists = array_unique( $ignore_lists );
752 - $ignore_lists = array_filter( $ignore_lists );
944 + $ignore_lists = array_filter( $ignore_lists, 'strlen' );
753 945 update_site_option( self::IGNORE_INDEXER, $ignore_lists );
754 946 }
755 947
756 948 /**
@@ -826,8 +1018,78 @@
826 1018 *
827 1019 * @return bool Return true on positive integer else false.
828 1020 * @since 2.6.1
829 1021 */
830 - private function is_positive_int( $id ) {
1022 + private function is_positive_int( $id ): bool {
831 1023 return is_int( $id ) && $id > 0;
1024 + }
1025 +
1026 + /**
1027 + * Check if an issue with the given type and full_path already exists under a specific status.
1028 + * Used to prevent adding duplicate entries to the ignored or active lists.
1029 + *
1030 + * @param array $current_issue_arr Associative array representation of the issue to check.
1031 + * @param string $status The status to search within (e.g. Scan_Item::STATUS_ACTIVE or STATUS_IGNORE).
1032 + *
1033 + * @return bool True if a matching issue already exists with that status.
1034 + */
1035 + private function issue_exists_with_status( array $current_issue_arr, string $status ): bool {
1036 + foreach ( $this->get_issues( null, $status ) as $existing_issue ) {
1037 + $existing_arr = $existing_issue->to_array();
1038 + if (
1039 + $current_issue_arr['type'] === $existing_arr['type']
1040 + && $current_issue_arr['full_path'] === $existing_arr['full_path']
1041 + ) {
1042 + return true;
1043 + }
1044 + }
1045 +
1046 + return false;
1047 + }
1048 +
1049 + /**
1050 + * Update the status of an issue and sync the global ignore indexer accordingly.
1051 + *
1052 + * When $new_status is STATUS_IGNORE the issue's full_path is appended to the indexer.
1053 + * When $new_status is STATUS_ACTIVE the issue's file/slug key is removed from the indexer.
1054 + *
1055 + * @param int $id The ID of the issue to update.
1056 + * @param string $new_status The target status (Scan_Item::STATUS_IGNORE or STATUS_ACTIVE).
1057 + * @param string $guard_status The status to check for existing duplicates before updating.
1058 + *
1059 + * @return bool True on success, false if the issue does not exist or a duplicate was found.
1060 + */
1061 + private function update_issue_status( int $id, string $new_status, string $guard_status ): bool {
1062 + $issue = $this->get_issue( $id );
1063 + if ( ! is_object( $issue ) ) {
1064 + return false;
1065 + }
1066 +
1067 + $current_issue_arr = $issue->to_array();
1068 + // Bail out if an identical issue already exists under the target status.
1069 + if ( $this->issue_exists_with_status( $current_issue_arr, $guard_status ) ) {
1070 + return false;
1071 + }
1072 +
1073 + $issue->status = $new_status;
1074 + $issue->save();
1075 +
1076 + $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
1077 +
1078 + if ( Scan_Item::STATUS_IGNORE === $new_status ) {
1079 + // Add this into a global ignored index and update the ignored list.
1080 + $ignore_lists[] = $current_issue_arr['full_path'];
1081 + } else {
1082 + // Remove the entry from the global ignored index.
1083 + $data = $issue->raw_data;
1084 + if ( isset( $data['file'] ) ) {
1085 + unset( $ignore_lists[ array_search( $data['file'], $ignore_lists, true ) ] );
1086 + } elseif ( isset( $data['slug'] ) ) {
1087 + unset( $ignore_lists[ array_search( $data['slug'], $ignore_lists, true ) ] );
1088 + }
1089 + }
1090 +
1091 + $this->update_ignore_list( $ignore_lists );
1092 +
1093 + return true;
832 1094 }
833 1095 }