PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-security-analyzer.php

class-security-analyzer.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 3.0.0, at includes/class-security-analyzer.php

674 lines 25.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Security Analyzer — Orchestrator.
4 *
5 * Runs the six check categories, aggregates their results into a scored
6 * report, persists the result and history, and powers the weekly cron
7 * with a regression-based email digest.
8 *
9 * @package Vigilante
10 * @since 2.1.0
11 */
12
13 // Prevent direct access.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Main entry point for the Security Analyzer.
20 *
21 * Lazy-loaded — instantiated from AJAX handlers and the weekly cron, not from
22 * Vigilante_Main::init_modules(), so the analyzer never runs on front-end hits.
23 */
24 class Vigilante_Security_Analyzer {
25
26 const OPTION_LAST_SCAN = 'vigilante_analyzer_last_scan';
27 const OPTION_HISTORY = 'vigilante_analyzer_history';
28 const OPTION_FIX_LOG = 'vigilante_analyzer_fix_log';
29 const HISTORY_LIMIT = 30;
30 // Legacy constant kept for backward compatibility. The real source of truth
31 // is total_max_points(), which sums the declared "max" of every category
32 // in get_categories(). Bump this only when manually verifying the sum.
33 const TOTAL_MAX_POINTS = 106;
34 const REGRESSION_THRESHOLD = 10; // Points dropped before sending the alert email.
35 /*
36 * Highest score a site can show while the self-protection check reports
37 * tampering: every other result of this report was computed by the same
38 * code whose files were reported as changed, so a high number next to it
39 * would be a lie. 29 is the top of grade E in compute_grade().
40 */
41 const SCORE_CAP_ON_TAMPER = 29;
42
43 /**
44 * Sum of the declared "max" of every category. This is the canonical
45 * maximum a scan can earn. Using this instead of the constant prevents
46 * the desync we hit in 2.6.0 (a new check raised the internal category
47 * max but the global total stayed at the pre-bump value).
48 *
49 * @return int
50 */
51 public static function total_max_points() {
52 $total = 0;
53 foreach ( self::get_categories() as $meta ) {
54 $total += (int) $meta['max'];
55 }
56 return $total;
57 }
58
59 /**
60 * @var Vigilante_Settings
61 */
62 private $settings;
63
64 /**
65 * @var Vigilante_Activity_Log|null
66 */
67 private $activity_log;
68
69 /**
70 * Metadata about each category used by the UI (label + weight).
71 *
72 * @return array<string,array{slug:string,label:string,max:int}>
73 */
74 public static function get_categories() {
75 return array(
76 'ssl' => array(
77 'slug' => 'ssl',
78 'label' => __( 'SSL / TLS', 'vigilante' ),
79 'max' => 12,
80 ),
81 'headers' => array(
82 'slug' => 'headers',
83 'label' => __( 'HTTP Security Headers', 'vigilante' ),
84 'max' => 18,
85 ),
86 'wp_exposure' => array(
87 'slug' => 'wp_exposure',
88 'label' => __( 'WordPress Exposure', 'vigilante' ),
89 'max' => 18,
90 ),
91 'access' => array(
92 'slug' => 'access',
93 'label' => __( 'Access & Authentication', 'vigilante' ),
94 'max' => 20,
95 ),
96 'files' => array(
97 'slug' => 'files',
98 'label' => __( 'Sensitive Files', 'vigilante' ),
99 'max' => 10,
100 ),
101 'internal' => array(
102 'slug' => 'internal',
103 'label' => __( 'Internal Checks (exclusive)', 'vigilante' ),
104 // Sum of the max values of every check in
105 // Vigilante_SA_Category_Internal. Update when adding/removing
106 // checks or changing their max value.
107 // 3.0.0: 30 -> 40 (self_integrity check added, worth 10: the
108 // integrity of the plugin that runs every other check).
109 'max' => 40,
110 ),
111 'reputation' => array(
112 'slug' => 'reputation',
113 'label' => __( 'Reputation / Blacklists', 'vigilante' ),
114 'max' => 0,
115 'info_only' => true,
116 ),
117 );
118 }
119
120 /**
121 * @param Vigilante_Settings $settings
122 * @param Vigilante_Activity_Log|null $activity_log
123 */
124 public function __construct( Vigilante_Settings $settings, $activity_log = null ) {
125 $this->settings = $settings;
126 $this->activity_log = $activity_log;
127 $this->require_dependencies();
128 }
129
130 /**
131 * Load category + helper classes (one-time per request).
132 */
133 private function require_dependencies() {
134 $dir = VIGILANTE_INCLUDES_DIR . 'security-analyzer/';
135 require_once $dir . 'class-sa-check-result.php';
136 require_once $dir . 'class-sa-helpers.php';
137 require_once $dir . 'class-sa-category-ssl.php';
138 require_once $dir . 'class-sa-category-headers.php';
139 require_once $dir . 'class-sa-category-wp-exposure.php';
140 require_once $dir . 'class-sa-category-access.php';
141 require_once $dir . 'class-sa-category-files.php';
142 require_once $dir . 'class-sa-category-internal.php';
143 require_once $dir . 'class-sa-category-reputation.php';
144 }
145
146 /**
147 * Run the scan in the requested phase and return the aggregated report.
148 *
149 * @param string $phase 'fast' | 'slow' | 'all'.
150 * @return array Report (see build_report()).
151 */
152 public function run_scan( $phase = 'all' ) {
153 if ( ! in_array( $phase, array( 'fast', 'slow', 'all' ), true ) ) {
154 $phase = 'all';
155 }
156
157 Vigilante_SA_Helpers::reset_cache();
158 $started = time();
159
160 $results = array();
161
162 $cat_ssl = new Vigilante_SA_Category_SSL( $this->settings );
163 $cat_headers = new Vigilante_SA_Category_Headers( $this->settings );
164 $cat_expose = new Vigilante_SA_Category_WP_Exposure( $this->settings );
165 $cat_access = new Vigilante_SA_Category_Access( $this->settings );
166 $cat_files = new Vigilante_SA_Category_Files( $this->settings );
167 $cat_intern = new Vigilante_SA_Category_Internal( $this->settings, $this->activity_log );
168 $cat_reput = new Vigilante_SA_Category_Reputation( $this->settings );
169
170 foreach ( $cat_ssl->run( $phase ) as $r ) {
171 $results[] = $r;
172 }
173 foreach ( $cat_headers->run( $phase ) as $r ) {
174 $results[] = $r;
175 }
176 foreach ( $cat_expose->run( $phase ) as $r ) {
177 $results[] = $r;
178 }
179 foreach ( $cat_access->run( $phase ) as $r ) {
180 $results[] = $r;
181 }
182 foreach ( $cat_files->run( $phase ) as $r ) {
183 $results[] = $r;
184 }
185 foreach ( $cat_intern->run( $phase ) as $r ) {
186 $results[] = $r;
187 }
188 foreach ( $cat_reput->run( $phase ) as $r ) {
189 $results[] = $r;
190 }
191
192 $report = $this->build_report( $results, $phase, $started );
193 $report['ran_at'] = $started;
194 $report['phase'] = $phase;
195 $report['elapsed'] = max( 0, time() - $started );
196
197 // Merge-persist: 'fast' and 'slow' phases each update part of the report;
198 // 'all' replaces everything. Dashboard widget shows whatever's latest.
199 $this->persist_scan( $report, $phase );
200
201 // Push to history only when the whole scan has finished (either 'all' in one call,
202 // or a 'slow' phase that immediately follows a 'fast' phase within the same minute).
203 if ( 'all' === $phase || 'slow' === $phase ) {
204 $this->push_history( $report );
205 }
206
207 return $report;
208 }
209
210 /**
211 * Aggregate check results into the scan report structure.
212 *
213 * @param Vigilante_SA_Check_Result[] $results
214 * @param string $phase
215 * @param int $started Unix timestamp.
216 * @return array
217 */
218 private function build_report( $results, $phase, $started ) {
219 $cat_meta = self::get_categories();
220
221 $categories = array();
222 foreach ( $cat_meta as $slug => $meta ) {
223 $categories[ $slug ] = array(
224 'slug' => $slug,
225 'label' => $meta['label'],
226 'max' => $meta['max'],
227 'earned' => 0,
228 'checks' => array(),
229 'counts' => array(
230 'pass' => 0,
231 'warn' => 0,
232 'fail' => 0,
233 'info' => 0,
234 'skip' => 0,
235 ),
236 );
237 }
238
239 $total_earned = 0;
240 $total_max = 0;
241 $counts = array(
242 'pass' => 0,
243 'warn' => 0,
244 'fail' => 0,
245 'info' => 0,
246 'skip' => 0,
247 );
248
249 foreach ( $results as $r ) {
250 if ( ! ( $r instanceof Vigilante_SA_Check_Result ) ) {
251 continue;
252 }
253 $slug = $r->category;
254 if ( ! isset( $categories[ $slug ] ) ) {
255 continue;
256 }
257 $categories[ $slug ]['checks'][] = $r->to_array();
258 $categories[ $slug ]['counts'][ $r->state ] = isset( $categories[ $slug ]['counts'][ $r->state ] )
259 ? $categories[ $slug ]['counts'][ $r->state ] + 1
260 : 1;
261 $counts[ $r->state ] = isset( $counts[ $r->state ] ) ? $counts[ $r->state ] + 1 : 1;
262
263 if ( $r->counts_for_score() ) {
264 $categories[ $slug ]['earned'] += $r->score;
265 $total_earned += $r->score;
266 $total_max += $r->max;
267 }
268 }
269
270 // Normalize against the declared category max so skipped checks don't erode the score.
271 $declared_max = self::total_max_points();
272 $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $declared_max );
273
274 // Self-protection caps the score (see SCORE_CAP_ON_TAMPER). The same
275 // cap is applied when phases are merged, in rebuild_from_categories().
276 $capped_by = '';
277 foreach ( $results as $r ) {
278 if ( $r instanceof Vigilante_SA_Check_Result
279 && 'self_integrity' === $r->id
280 && Vigilante_SA_Check_Result::STATE_FAIL === $r->state ) {
281 $capped_by = 'self_integrity';
282 break;
283 }
284 }
285 if ( 'self_integrity' === $capped_by && $grade['score'] > self::SCORE_CAP_ON_TAMPER ) {
286 $grade['score'] = self::SCORE_CAP_ON_TAMPER;
287 $grade['grade'] = 'E';
288 }
289
290 return array(
291 'ran_at' => $started,
292 'phase' => $phase,
293 'total_earned' => $total_earned,
294 'total_max' => $declared_max,
295 'total_evaluated'=> $total_max, // Actual evaluated max (excluding skipped).
296 'score' => $grade['score'],
297 'grade' => $grade['grade'],
298 'capped_by' => $capped_by,
299 'counts' => $counts,
300 'categories' => $categories,
301 );
302 }
303
304 /**
305 * Merge the new partial/full scan with whatever was last persisted.
306 * Fast + slow phases arrive separately from the UI; we store a consistent
307 * merged report so reloading the Dashboard shows complete data.
308 *
309 * @param array $report
310 * @param string $phase
311 */
312 private function persist_scan( array $report, $phase ) {
313 if ( 'all' === $phase ) {
314 update_option( self::OPTION_LAST_SCAN, $report, false );
315 return;
316 }
317
318 $existing = $this->get_last_scan();
319 if ( ! is_array( $existing ) ) {
320 update_option( self::OPTION_LAST_SCAN, $report, false );
321 return;
322 }
323
324 // Merge: only replace categories whose checks are actually present in the new phase.
325 foreach ( $report['categories'] as $slug => $cat ) {
326 if ( empty( $cat['checks'] ) ) {
327 continue;
328 }
329 // Track which check ids were produced now so we can overwrite them selectively.
330 $new_ids = array();
331 foreach ( $cat['checks'] as $c ) {
332 if ( isset( $c['id'] ) ) {
333 $new_ids[ $c['id'] ] = true;
334 }
335 }
336
337 // Start from existing category bucket (preserve other-phase checks).
338 if ( ! isset( $existing['categories'][ $slug ] ) ) {
339 $existing['categories'][ $slug ] = $cat;
340 continue;
341 }
342 $merged_checks = array();
343 foreach ( (array) $existing['categories'][ $slug ]['checks'] as $old_c ) {
344 if ( isset( $old_c['id'] ) && isset( $new_ids[ $old_c['id'] ] ) ) {
345 continue; // Will be replaced below.
346 }
347 $merged_checks[] = $old_c;
348 }
349 foreach ( $cat['checks'] as $new_c ) {
350 $merged_checks[] = $new_c;
351 }
352 $existing['categories'][ $slug ]['checks'] = $merged_checks;
353 }
354
355 // Recompute totals from the merged categories.
356 $rebuilt = $this->rebuild_from_categories( $existing['categories'] );
357 $existing = array_merge( $existing, $rebuilt );
358 $existing['ran_at'] = $report['ran_at'];
359 $existing['phase'] = $phase;
360 $existing['elapsed'] = isset( $report['elapsed'] ) ? $report['elapsed'] : 0;
361
362 update_option( self::OPTION_LAST_SCAN, $existing, false );
363 }
364
365 /**
366 * Recompute totals and grade from a categories array (used when merging phases).
367 *
368 * @param array $categories
369 * @return array subset with total_earned, total_max, total_evaluated, score, grade, counts.
370 */
371 private function rebuild_from_categories( array $categories ) {
372 $total_earned = 0;
373 $total_evaluated = 0;
374 $counts = array(
375 'pass' => 0,
376 'warn' => 0,
377 'fail' => 0,
378 'info' => 0,
379 'skip' => 0,
380 );
381
382 // Resolve the canonical "max" of every category from the declared meta.
383 // Without this, merging fast/slow phases preserves a stale "max" from
384 // the previous scan, which is what produced the 28/22 desync after
385 // 2.6.0 added the closed_plugins check.
386 $cat_meta = self::get_categories();
387
388 foreach ( $categories as $slug => $cat ) {
389 $cat_earned = 0;
390 $cat_counts = array(
391 'pass' => 0,
392 'warn' => 0,
393 'fail' => 0,
394 'info' => 0,
395 'skip' => 0,
396 );
397 foreach ( (array) $cat['checks'] as $c ) {
398 $state = isset( $c['state'] ) ? $c['state'] : Vigilante_SA_Check_Result::STATE_SKIP;
399 $cat_counts[ $state ] = isset( $cat_counts[ $state ] ) ? $cat_counts[ $state ] + 1 : 1;
400 $counts[ $state ] = isset( $counts[ $state ] ) ? $counts[ $state ] + 1 : 1;
401
402 if ( Vigilante_SA_Check_Result::STATE_INFO === $state || Vigilante_SA_Check_Result::STATE_SKIP === $state ) {
403 continue;
404 }
405 $cat_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
406 $total_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
407 $total_evaluated += isset( $c['max'] ) ? (int) $c['max'] : 0;
408 }
409 $categories[ $slug ]['earned'] = $cat_earned;
410 $categories[ $slug ]['counts'] = $cat_counts;
411
412 // Force the canonical max so old cached scans get repaired the
413 // moment they're touched (no need to wait for a clean "all" phase).
414 if ( isset( $cat_meta[ $slug ]['max'] ) ) {
415 $categories[ $slug ]['max'] = (int) $cat_meta[ $slug ]['max'];
416 }
417 }
418
419 $total_max = self::total_max_points();
420 $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
421
422 // Self-protection caps the score (see SCORE_CAP_ON_TAMPER).
423 $capped_by = '';
424 foreach ( $categories as $cat ) {
425 foreach ( (array) $cat['checks'] as $c ) {
426 if ( 'self_integrity' === ( isset( $c['id'] ) ? $c['id'] : '' )
427 && Vigilante_SA_Check_Result::STATE_FAIL === ( isset( $c['state'] ) ? $c['state'] : '' ) ) {
428 $capped_by = 'self_integrity';
429 break 2;
430 }
431 }
432 }
433 if ( 'self_integrity' === $capped_by && $grade['score'] > self::SCORE_CAP_ON_TAMPER ) {
434 $grade['score'] = self::SCORE_CAP_ON_TAMPER;
435 $grade['grade'] = 'E';
436 }
437
438 return array(
439 'categories' => $categories,
440 'total_earned' => $total_earned,
441 'total_max' => $total_max,
442 'total_evaluated'=> $total_evaluated,
443 'score' => $grade['score'],
444 'grade' => $grade['grade'],
445 'capped_by' => $capped_by,
446 'counts' => $counts,
447 );
448 }
449
450 /**
451 * Append a minimal history entry (no per-check detail) to the circular buffer.
452 *
453 * @param array $report
454 */
455 private function push_history( array $report ) {
456 $history = $this->get_score_history( self::HISTORY_LIMIT );
457 if ( ! is_array( $history ) ) {
458 $history = array();
459 }
460
461 $entry = array(
462 'ran_at' => (int) $report['ran_at'],
463 'score' => (int) $report['score'],
464 'grade' => (string) $report['grade'],
465 'total_earned' => (int) $report['total_earned'],
466 'categories' => array(),
467 );
468 foreach ( $report['categories'] as $slug => $cat ) {
469 $entry['categories'][ $slug ] = array(
470 'earned' => (int) $cat['earned'],
471 'max' => (int) $cat['max'],
472 );
473 }
474
475 $history[] = $entry;
476 if ( count( $history ) > self::HISTORY_LIMIT ) {
477 $history = array_slice( $history, -self::HISTORY_LIMIT );
478 }
479 update_option( self::OPTION_HISTORY, $history, false );
480 }
481
482 /**
483 * Return the last persisted scan, or an empty placeholder structure.
484 *
485 * @return array
486 */
487 public function get_last_scan() {
488 $raw = get_option( self::OPTION_LAST_SCAN, null );
489 if ( ! is_array( $raw ) ) {
490 return array(
491 'ran_at' => 0,
492 'score' => 0,
493 'grade' => '',
494 'total_earned' => 0,
495 'total_max' => self::total_max_points(),
496 'counts' => array(
497 'pass' => 0,
498 'warn' => 0,
499 'fail' => 0,
500 'info' => 0,
501 'skip' => 0,
502 ),
503 'categories' => array(),
504 );
505 }
506 return $raw;
507 }
508
509 /**
510 * Return history (oldest first, max = HISTORY_LIMIT).
511 *
512 * @param int $limit
513 * @return array
514 */
515 public function get_score_history( $limit = self::HISTORY_LIMIT ) {
516 $raw = get_option( self::OPTION_HISTORY, array() );
517 if ( ! is_array( $raw ) ) {
518 return array();
519 }
520 $limit = max( 1, (int) $limit );
521 return array_slice( $raw, -$limit );
522 }
523
524 /**
525 * Return the catalog for UI (check labels, max points, fix links).
526 * Built from a single dry-run to avoid maintaining a duplicate mapping.
527 *
528 * @return array
529 */
530 public function get_catalog() {
531 $cats = self::get_categories();
532 $out = array();
533 foreach ( $cats as $slug => $meta ) {
534 $out[ $slug ] = array(
535 'slug' => $slug,
536 'label' => $meta['label'],
537 'max' => $meta['max'],
538 'checks' => array(),
539 );
540 }
541 return $out;
542 }
543
544 /**
545 * Weekly cron handler — run a full scan and email the admin when the score
546 * has dropped by >= REGRESSION_THRESHOLD points vs the previous history entry,
547 * or when new critical failures appeared.
548 */
549 public function cron_weekly_scan() {
550 $previous = $this->get_last_scan();
551 $report = $this->run_scan( 'all' );
552
553 // Bail if notifications aren't wanted.
554 $analyzer_settings = (array) $this->settings->get_section( 'security_analyzer' );
555 $weekly_enabled = ! isset( $analyzer_settings['weekly_scan_enabled'] ) || ! empty( $analyzer_settings['weekly_scan_enabled'] );
556 $email_enabled = ! empty( $analyzer_settings['email_on_regression'] );
557
558 if ( ! $weekly_enabled || ! $email_enabled ) {
559 return;
560 }
561
562 if ( empty( $previous['score'] ) ) {
563 return; // First run: never email.
564 }
565
566 $delta = (int) $previous['score'] - (int) $report['score'];
567 $new_fails = $this->diff_new_failures( $previous, $report );
568
569 if ( $delta < self::REGRESSION_THRESHOLD && empty( $new_fails ) ) {
570 return;
571 }
572
573 $this->send_regression_email( $previous, $report, $new_fails );
574 }
575
576 /**
577 * Collect check ids that are FAIL now but weren't FAIL (were PASS/WARN/INFO/SKIP) before.
578 *
579 * @param array $prev
580 * @param array $curr
581 * @return array<int,array{label:string,category:string,id:string}>
582 */
583 private function diff_new_failures( array $prev, array $curr ) {
584 $prev_states = array();
585 foreach ( (array) ( $prev['categories'] ?? array() ) as $slug => $cat ) {
586 foreach ( (array) ( $cat['checks'] ?? array() ) as $c ) {
587 if ( isset( $c['id'] ) ) {
588 $prev_states[ $c['id'] ] = isset( $c['state'] ) ? $c['state'] : '';
589 }
590 }
591 }
592
593 $diffs = array();
594 foreach ( (array) ( $curr['categories'] ?? array() ) as $slug => $cat ) {
595 foreach ( (array) ( $cat['checks'] ?? array() ) as $c ) {
596 if ( ! isset( $c['id'], $c['state'] ) ) {
597 continue;
598 }
599 if ( Vigilante_SA_Check_Result::STATE_FAIL !== $c['state'] ) {
600 continue;
601 }
602 $was = isset( $prev_states[ $c['id'] ] ) ? $prev_states[ $c['id'] ] : '';
603 if ( Vigilante_SA_Check_Result::STATE_FAIL === $was ) {
604 continue;
605 }
606 $diffs[] = array(
607 'id' => $c['id'],
608 'label' => isset( $c['label'] ) ? $c['label'] : $c['id'],
609 'category' => $slug,
610 );
611 }
612 }
613 return $diffs;
614 }
615
616 /**
617 * Send the regression alert email.
618 *
619 * @param array $prev Previous scan.
620 * @param array $curr Current scan.
621 * @param array $new_fails New failures diff.
622 */
623 private function send_regression_email( array $prev, array $curr, array $new_fails ) {
624 if ( ! class_exists( 'Vigilante_Email_Template' ) ) {
625 require_once VIGILANTE_INCLUDES_DIR . 'class-email-template.php';
626 }
627
628 $to = Vigilante_Email_Template::get_admin_recipients();
629 if ( empty( $to ) ) {
630 return;
631 }
632
633 $subject = sprintf(
634 /* translators: 1: previous grade, 2: previous score, 3: current grade, 4: current score */
635 __( '[Vigilant] Security Score dropped from %1$s (%2$d) to %3$s (%4$d)', 'vigilante' ),
636 $prev['grade'],
637 (int) $prev['score'],
638 $curr['grade'],
639 (int) $curr['score']
640 );
641
642 $body = '<p>' . esc_html(
643 sprintf(
644 /* translators: 1: site name */
645 __( 'Vigilant ran the weekly Security Check on %s and the result changed.', 'vigilante' ),
646 get_bloginfo( 'name' )
647 )
648 ) . '</p>';
649
650 $body .= Vigilante_Email_Template::data_table(
651 array(
652 __( 'Previous Score', 'vigilante' ) => $prev['grade'] . '' . (int) $prev['score'] . '/100',
653 __( 'Current Score', 'vigilante' ) => $curr['grade'] . '' . (int) $curr['score'] . '/100',
654 __( 'Points lost', 'vigilante' ) => (int) $prev['score'] - (int) $curr['score'],
655 __( 'New failing checks', 'vigilante' ) => count( $new_fails ),
656 )
657 );
658
659 if ( ! empty( $new_fails ) ) {
660 $body .= '<h3>' . esc_html__( 'Checks that started failing', 'vigilante' ) . '</h3>';
661 $body .= '<ul>';
662 foreach ( $new_fails as $f ) {
663 $body .= '<li><strong>' . esc_html( $f['label'] ) . '</strong> <em>(' . esc_html( $f['category'] ) . ')</em></li>';
664 }
665 $body .= '</ul>';
666 }
667
668 $report_url = admin_url( 'admin.php?page=vigilante&tab=dashboard#vigilante-analyzer' );
669 $body .= '<p><a href="' . esc_url( $report_url ) . '" style="display:inline-block;background:#2271b1;color:#fff;padding:8px 16px;text-decoration:none;border-radius:4px;">' . esc_html__( 'View full report', 'vigilante' ) . '</a></p>';
670
671 Vigilante_Email_Template::send( $to, $subject, __( 'Security Check regression detected', 'vigilante' ), $body, true );
672 }
673 }
674