PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 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 All 93 releases
vigilante / includes / class-security-analyzer.php

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

702 lines 26.5 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 const REGRESSION_THRESHOLD = 10; // Points dropped before sending the alert email.
31 /*
32 * Highest score a site can show while the self-protection check reports
33 * tampering: every other result of this report was computed by the same
34 * code whose files were reported as changed, so a high number next to it
35 * would be a lie. 29 sits inside grade E of
36 * Vigilante_SA_Helpers::grade_for_score().
37 */
38 const SCORE_CAP_ON_TAMPER = 29;
39
40 /**
41 * Sum of the declared "max" of every category: what a scan earns when
42 * every check can be measured. It is what the widget shows before the
43 * first scan. A report is graded against what it did measure, which
44 * summarize_categories() adds up from the checks themselves.
45 *
46 * @return int
47 */
48 public static function total_max_points() {
49 $total = 0;
50 foreach ( self::get_categories() as $meta ) {
51 $total += (int) $meta['max'];
52 }
53 return $total;
54 }
55
56 /**
57 * @var Vigilante_Settings
58 */
59 private $settings;
60
61 /**
62 * @var Vigilante_Activity_Log|null
63 */
64 private $activity_log;
65
66 /**
67 * Metadata about each category used by the UI (label + weight).
68 *
69 * @return array<string,array{slug:string,label:string,max:int}>
70 */
71 public static function get_categories() {
72 return array(
73 'ssl' => array(
74 'slug' => 'ssl',
75 'label' => __( 'SSL / TLS', 'vigilante' ),
76 'max' => 12,
77 ),
78 'headers' => array(
79 'slug' => 'headers',
80 'label' => __( 'HTTP Security Headers', 'vigilante' ),
81 'max' => 18,
82 ),
83 'wp_exposure' => array(
84 'slug' => 'wp_exposure',
85 'label' => __( 'WordPress Exposure', 'vigilante' ),
86 // 18 -> 13: the active theme row is informational now. Whether
87 // the theme has an update pending is already scored by the
88 // theme_updates check of the internal category.
89 'max' => 13,
90 ),
91 'access' => array(
92 'slug' => 'access',
93 'label' => __( 'Access & Authentication', 'vigilante' ),
94 'max' => 18,
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 ) {
204 $this->push_history( $report );
205 } elseif ( 'slow' === $phase ) {
206 // The slow phase closes a scan the fast one started, so what goes
207 // to the history is the two merged, as persist_scan() has just
208 // stored them. The report of this phase alone carries only its own
209 // checks, and the trend showed every manual scan as a collapse.
210 $this->push_history( $this->get_last_scan() );
211 }
212
213 return $report;
214 }
215
216 /**
217 * Aggregate check results into the scan report structure.
218 *
219 * @param Vigilante_SA_Check_Result[] $results
220 * @param string $phase
221 * @param int $started Unix timestamp.
222 * @return array
223 */
224 private function build_report( $results, $phase, $started ) {
225 $categories = array();
226 foreach ( self::get_categories() as $slug => $meta ) {
227 $categories[ $slug ] = array(
228 'slug' => $slug,
229 'label' => $meta['label'],
230 'max' => 0,
231 'earned' => 0,
232 'checks' => array(),
233 'counts' => array(
234 'pass' => 0,
235 'warn' => 0,
236 'fail' => 0,
237 'info' => 0,
238 'skip' => 0,
239 ),
240 );
241 }
242
243 foreach ( $results as $r ) {
244 if ( ! ( $r instanceof Vigilante_SA_Check_Result ) || ! isset( $categories[ $r->category ] ) ) {
245 continue;
246 }
247 $categories[ $r->category ]['checks'][] = $r->to_array();
248 }
249
250 return array_merge(
251 array(
252 'ran_at' => $started,
253 'phase' => $phase,
254 ),
255 self::summarize_categories( $categories )
256 );
257 }
258
259 /**
260 * Merge the new partial/full scan with whatever was last persisted.
261 * Fast + slow phases arrive separately from the UI; we store a consistent
262 * merged report so reloading the Dashboard shows complete data.
263 *
264 * @param array $report
265 * @param string $phase
266 */
267 private function persist_scan( array $report, $phase ) {
268 if ( 'all' === $phase ) {
269 update_option( self::OPTION_LAST_SCAN, $report, false );
270 return;
271 }
272
273 $existing = $this->get_last_scan();
274 if ( ! is_array( $existing ) ) {
275 update_option( self::OPTION_LAST_SCAN, $report, false );
276 return;
277 }
278
279 // Merge: only replace categories whose checks are actually present in the new phase.
280 foreach ( $report['categories'] as $slug => $cat ) {
281 if ( empty( $cat['checks'] ) ) {
282 continue;
283 }
284 // Track which check ids were produced now so we can overwrite them selectively.
285 $new_ids = array();
286 foreach ( $cat['checks'] as $c ) {
287 if ( isset( $c['id'] ) ) {
288 $new_ids[ $c['id'] ] = true;
289 }
290 }
291
292 // Start from existing category bucket (preserve other-phase checks).
293 if ( ! isset( $existing['categories'][ $slug ] ) ) {
294 $existing['categories'][ $slug ] = $cat;
295 continue;
296 }
297 $merged_checks = array();
298 foreach ( (array) $existing['categories'][ $slug ]['checks'] as $old_c ) {
299 if ( isset( $old_c['id'] ) && isset( $new_ids[ $old_c['id'] ] ) ) {
300 continue; // Will be replaced below.
301 }
302 $merged_checks[] = $old_c;
303 }
304 foreach ( $cat['checks'] as $new_c ) {
305 $merged_checks[] = $new_c;
306 }
307 $existing['categories'][ $slug ]['checks'] = $merged_checks;
308 }
309
310 // Recompute totals from the merged categories, with the ones that have
311 // not run yet in their place: the loop above only copies those that
312 // came with checks.
313 $rebuilt = self::summarize_categories( self::with_every_category( $existing['categories'] ) );
314 $existing = array_merge( $existing, $rebuilt );
315 $existing['ran_at'] = $report['ran_at'];
316 $existing['phase'] = $phase;
317 $existing['elapsed'] = isset( $report['elapsed'] ) ? $report['elapsed'] : 0;
318
319 update_option( self::OPTION_LAST_SCAN, $existing, false );
320 }
321
322 /**
323 * Totals, counts and grade of a set of categories, added up from their checks
324 *
325 * The one place where a report is scored: a fresh scan, two phases merged
326 * and a stored report read back all go through here.
327 *
328 * A check that could not be measured (skip) and one that is only
329 * informative (info) are left out of both sides of the fraction, in their
330 * category and in the total. Until 3.0.4 the score was divided by the
331 * declared maximum of every category, so each of them counted as a zero:
332 * a probe that timed out lowered the score as if the check had failed, and
333 * a site that had switched COOP off on purpose could not reach 100. The
334 * maximum of a category is therefore the sum of the checks it did score,
335 * which also means it cannot drift from them when a check is added.
336 *
337 * A category none of whose checks has run yet is another matter: the scan
338 * is not finished. It counts with its declared maximum and nothing earned,
339 * or a first scan whose remote phase never arrives (a timeout, a closed
340 * tab) would be graded over the local checks alone and read better than a
341 * finished one.
342 *
343 * @since 3.0.5
344 *
345 * @param array $categories Categories with their checks.
346 * @return array categories, total_earned, total_max, total_evaluated, score, grade, capped_by, counts.
347 */
348 public static function summarize_categories( array $categories ) {
349 $total_earned = 0;
350 $total_max = 0;
351 $capped_by = '';
352 $counts = array(
353 'pass' => 0,
354 'warn' => 0,
355 'fail' => 0,
356 'info' => 0,
357 'skip' => 0,
358 );
359
360 $declared = self::get_categories();
361 $started = false;
362 foreach ( $categories as $cat ) {
363 if ( is_array( $cat ) && ! empty( $cat['checks'] ) ) {
364 $started = true;
365 break;
366 }
367 }
368
369 foreach ( $categories as $slug => $cat ) {
370 $cat_earned = 0;
371 $cat_max = 0;
372 $cat_counts = array(
373 'pass' => 0,
374 'warn' => 0,
375 'fail' => 0,
376 'info' => 0,
377 'skip' => 0,
378 );
379 $checks = ( is_array( $cat ) && isset( $cat['checks'] ) ) ? (array) $cat['checks'] : array();
380
381 // Not run yet, in a scan that has started: pending, in full.
382 if ( empty( $checks ) && $started && isset( $declared[ $slug ] ) && empty( $declared[ $slug ]['info_only'] ) ) {
383 $cat_max = (int) $declared[ $slug ]['max'];
384 }
385
386 foreach ( $checks as $c ) {
387 $state = isset( $c['state'] ) ? (string) $c['state'] : Vigilante_SA_Check_Result::STATE_SKIP;
388 $cat_counts[ $state ] = isset( $cat_counts[ $state ] ) ? $cat_counts[ $state ] + 1 : 1;
389 $counts[ $state ] = isset( $counts[ $state ] ) ? $counts[ $state ] + 1 : 1;
390
391 // Self-protection caps the score (see SCORE_CAP_ON_TAMPER).
392 if ( 'self_integrity' === ( isset( $c['id'] ) ? $c['id'] : '' )
393 && Vigilante_SA_Check_Result::STATE_FAIL === $state ) {
394 $capped_by = 'self_integrity';
395 }
396
397 if ( Vigilante_SA_Check_Result::STATE_INFO === $state || Vigilante_SA_Check_Result::STATE_SKIP === $state ) {
398 continue;
399 }
400 $cat_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
401 $cat_max += isset( $c['max'] ) ? (int) $c['max'] : 0;
402 }
403
404 if ( ! is_array( $cat ) ) {
405 $categories[ $slug ] = array();
406 }
407 $categories[ $slug ]['earned'] = $cat_earned;
408 $categories[ $slug ]['max'] = $cat_max;
409 $categories[ $slug ]['counts'] = $cat_counts;
410
411 $total_earned += $cat_earned;
412 $total_max += $cat_max;
413 }
414
415 $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
416
417 if ( 'self_integrity' === $capped_by && $grade['score'] > self::SCORE_CAP_ON_TAMPER ) {
418 $grade['score'] = self::SCORE_CAP_ON_TAMPER;
419 $grade['grade'] = Vigilante_SA_Helpers::grade_for_score( self::SCORE_CAP_ON_TAMPER );
420 }
421
422 return array(
423 'categories' => $categories,
424 'total_earned' => $total_earned,
425 'total_max' => $total_max,
426 // Same figure as total_max since 3.0.5, kept for whoever reads it.
427 'total_evaluated' => $total_max,
428 'score' => $grade['score'],
429 'grade' => $grade['grade'],
430 'capped_by' => $capped_by,
431 'counts' => $counts,
432 );
433 }
434
435 /**
436 * The categories of a report, with every declared one present
437 *
438 * A report stored after the local phase alone only holds the categories
439 * that phase filled, and summarize_categories() can only count as pending
440 * a category it is given. Without this, a first scan whose remote phase
441 * never arrived was graded over the four categories it had: 100 over 85
442 * points where the whole report gives 75 over 113. Found by the second
443 * cross review of 3.0.5.
444 *
445 * @since 3.0.5
446 *
447 * @param array $categories Categories of a stored or merged report.
448 * @return array The same, plus an empty entry for each one that was missing.
449 */
450 private static function with_every_category( array $categories ) {
451 foreach ( self::get_categories() as $slug => $meta ) {
452 if ( isset( $categories[ $slug ] ) && is_array( $categories[ $slug ] ) ) {
453 continue;
454 }
455 $categories[ $slug ] = array(
456 'slug' => $slug,
457 'label' => $meta['label'],
458 'max' => 0,
459 'earned' => 0,
460 'checks' => array(),
461 );
462 }
463
464 return $categories;
465 }
466
467 /**
468 * Append a minimal history entry (no per-check detail) to the circular buffer.
469 *
470 * @param array $report
471 */
472 private function push_history( array $report ) {
473 $history = $this->get_score_history( self::HISTORY_LIMIT );
474 if ( ! is_array( $history ) ) {
475 $history = array();
476 }
477
478 $entry = array(
479 'ran_at' => (int) $report['ran_at'],
480 'score' => (int) $report['score'],
481 'grade' => (string) $report['grade'],
482 'total_earned' => (int) $report['total_earned'],
483 'categories' => array(),
484 );
485 foreach ( $report['categories'] as $slug => $cat ) {
486 $entry['categories'][ $slug ] = array(
487 'earned' => (int) $cat['earned'],
488 'max' => (int) $cat['max'],
489 );
490 }
491
492 $history[] = $entry;
493 if ( count( $history ) > self::HISTORY_LIMIT ) {
494 $history = array_slice( $history, -self::HISTORY_LIMIT );
495 }
496 update_option( self::OPTION_HISTORY, $history, false );
497 }
498
499 /**
500 * Return the last persisted scan, or an empty placeholder structure.
501 *
502 * @return array
503 */
504 public function get_last_scan() {
505 $raw = get_option( self::OPTION_LAST_SCAN, null );
506 if ( ! is_array( $raw ) ) {
507 return array(
508 'ran_at' => 0,
509 'score' => 0,
510 'grade' => '',
511 'total_earned' => 0,
512 'total_max' => self::total_max_points(),
513 'counts' => array(
514 'pass' => 0,
515 'warn' => 0,
516 'fail' => 0,
517 'info' => 0,
518 'skip' => 0,
519 ),
520 'categories' => array(),
521 );
522 }
523
524 // A stored report is read back under the rules in force: its checks
525 // are the ones that ran, and what they add up to and the letter they
526 // earn are worked out now. Otherwise the widget would show the sum
527 // and the letter of the version that stored it until the next scan,
528 // and the weekly comparison would set a score against one worked out
529 // differently.
530 if ( isset( $raw['categories'] ) && is_array( $raw['categories'] ) ) {
531 $raw = array_merge( $raw, self::summarize_categories( self::with_every_category( $raw['categories'] ) ) );
532 }
533
534 return $raw;
535 }
536
537 /**
538 * Return history (oldest first, max = HISTORY_LIMIT).
539 *
540 * @param int $limit
541 * @return array
542 */
543 public function get_score_history( $limit = self::HISTORY_LIMIT ) {
544 $raw = get_option( self::OPTION_HISTORY, array() );
545 if ( ! is_array( $raw ) ) {
546 return array();
547 }
548 $limit = max( 1, (int) $limit );
549 return array_slice( $raw, -$limit );
550 }
551
552 /**
553 * Return the catalog for UI (check labels, max points, fix links).
554 * Built from a single dry-run to avoid maintaining a duplicate mapping.
555 *
556 * @return array
557 */
558 public function get_catalog() {
559 $cats = self::get_categories();
560 $out = array();
561 foreach ( $cats as $slug => $meta ) {
562 $out[ $slug ] = array(
563 'slug' => $slug,
564 'label' => $meta['label'],
565 'max' => $meta['max'],
566 'checks' => array(),
567 );
568 }
569 return $out;
570 }
571
572 /**
573 * Weekly cron handler — run a full scan and email the admin when the score
574 * has dropped by >= REGRESSION_THRESHOLD points vs the previous history entry,
575 * or when new critical failures appeared.
576 */
577 public function cron_weekly_scan() {
578 $previous = $this->get_last_scan();
579 $report = $this->run_scan( 'all' );
580
581 // Bail if notifications aren't wanted.
582 $analyzer_settings = (array) $this->settings->get_section( 'security_analyzer' );
583 $weekly_enabled = ! isset( $analyzer_settings['weekly_scan_enabled'] ) || ! empty( $analyzer_settings['weekly_scan_enabled'] );
584 $email_enabled = ! empty( $analyzer_settings['email_on_regression'] );
585
586 if ( ! $weekly_enabled || ! $email_enabled ) {
587 return;
588 }
589
590 if ( empty( $previous['score'] ) ) {
591 return; // First run: never email.
592 }
593
594 $delta = (int) $previous['score'] - (int) $report['score'];
595 $new_fails = $this->diff_new_failures( $previous, $report );
596
597 if ( $delta < self::REGRESSION_THRESHOLD && empty( $new_fails ) ) {
598 return;
599 }
600
601 $this->send_regression_email( $previous, $report, $new_fails );
602 }
603
604 /**
605 * Collect check ids that are FAIL now but weren't FAIL (were PASS/WARN/INFO/SKIP) before.
606 *
607 * @param array $prev
608 * @param array $curr
609 * @return array<int,array{label:string,category:string,id:string}>
610 */
611 private function diff_new_failures( array $prev, array $curr ) {
612 $prev_states = array();
613 foreach ( (array) ( $prev['categories'] ?? array() ) as $slug => $cat ) {
614 foreach ( (array) ( $cat['checks'] ?? array() ) as $c ) {
615 if ( isset( $c['id'] ) ) {
616 $prev_states[ $c['id'] ] = isset( $c['state'] ) ? $c['state'] : '';
617 }
618 }
619 }
620
621 $diffs = array();
622 foreach ( (array) ( $curr['categories'] ?? array() ) as $slug => $cat ) {
623 foreach ( (array) ( $cat['checks'] ?? array() ) as $c ) {
624 if ( ! isset( $c['id'], $c['state'] ) ) {
625 continue;
626 }
627 if ( Vigilante_SA_Check_Result::STATE_FAIL !== $c['state'] ) {
628 continue;
629 }
630 $was = isset( $prev_states[ $c['id'] ] ) ? $prev_states[ $c['id'] ] : '';
631 if ( Vigilante_SA_Check_Result::STATE_FAIL === $was ) {
632 continue;
633 }
634 $diffs[] = array(
635 'id' => $c['id'],
636 'label' => isset( $c['label'] ) ? $c['label'] : $c['id'],
637 'category' => $slug,
638 );
639 }
640 }
641 return $diffs;
642 }
643
644 /**
645 * Send the regression alert email.
646 *
647 * @param array $prev Previous scan.
648 * @param array $curr Current scan.
649 * @param array $new_fails New failures diff.
650 */
651 private function send_regression_email( array $prev, array $curr, array $new_fails ) {
652 if ( ! class_exists( 'Vigilante_Email_Template' ) ) {
653 require_once VIGILANTE_INCLUDES_DIR . 'class-email-template.php';
654 }
655
656 $to = Vigilante_Email_Template::get_admin_recipients();
657 if ( empty( $to ) ) {
658 return;
659 }
660
661 $subject = sprintf(
662 /* translators: 1: previous grade, 2: previous score, 3: current grade, 4: current score */
663 __( '[Vigilant] Security Score dropped from %1$s (%2$d) to %3$s (%4$d)', 'vigilante' ),
664 $prev['grade'],
665 (int) $prev['score'],
666 $curr['grade'],
667 (int) $curr['score']
668 );
669
670 $body = '<p>' . esc_html(
671 sprintf(
672 /* translators: 1: site name */
673 __( 'Vigilant ran the weekly Security Check on %s and the result changed.', 'vigilante' ),
674 get_bloginfo( 'name' )
675 )
676 ) . '</p>';
677
678 $body .= Vigilante_Email_Template::data_table(
679 array(
680 __( 'Previous Score', 'vigilante' ) => $prev['grade'] . ' — ' . (int) $prev['score'] . '/100',
681 __( 'Current Score', 'vigilante' ) => $curr['grade'] . ' — ' . (int) $curr['score'] . '/100',
682 __( 'Points lost', 'vigilante' ) => (int) $prev['score'] - (int) $curr['score'],
683 __( 'New failing checks', 'vigilante' ) => count( $new_fails ),
684 )
685 );
686
687 if ( ! empty( $new_fails ) ) {
688 $body .= '<h3>' . esc_html__( 'Checks that started failing', 'vigilante' ) . '</h3>';
689 $body .= '<ul>';
690 foreach ( $new_fails as $f ) {
691 $body .= '<li><strong>' . esc_html( $f['label'] ) . '</strong> <em>(' . esc_html( $f['category'] ) . ')</em></li>';
692 }
693 $body .= '</ul>';
694 }
695
696 $report_url = admin_url( 'admin.php?page=vigilante&tab=dashboard#vigilante-analyzer' );
697 $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>';
698
699 Vigilante_Email_Template::send( $to, $subject, __( 'Security Check regression detected', 'vigilante' ), $body, true );
700 }
701 }
702