PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.4
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 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-security-analyzer.php

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

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