PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.10.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.10.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 / security-analyzer / class-sa-category-internal.php

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

842 lines 32.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Security Analyzer — Internal-exclusive category (30 pts).
4 *
5 * The differential of this analyzer vs any external scanner. Each check
6 * reads data that is impossible to observe from the outside.
7 *
8 * Checks:
9 * - php_version_eol (3)
10 * - wp_core_updates (3)
11 * - plugin_updates (2)
12 * - theme_updates (1)
13 * - inactive_plugins (2)
14 * - closed_plugins (3) ← v2.6.0: reads the cached state from the daily
15 * Vigilante_Plugin_Status check; no extra HTTP call.
16 * - file_permissions (2)
17 * - salts_default (2)
18 * - table_prefix (2)
19 * - admin_username (2)
20 * - admins_without_2fa (2)
21 * - vigilante_modules_off (2)
22 * - activity_log_errors (1)
23 * - file_integrity_status (1)
24 * - audit_alerts_active (2) ← v2.8.0: warns when Security Audit is on but no
25 * audit alert is configured; skips when off.
26 *
27 * @package Vigilante
28 * @since 2.1.0
29 */
30
31 // Prevent direct access.
32 if ( ! defined( 'ABSPATH' ) ) {
33 exit;
34 }
35
36 /**
37 * Internal-only checks.
38 */
39 class Vigilante_SA_Category_Internal {
40
41 const SLUG = 'internal';
42
43 /**
44 * @var Vigilante_Settings
45 */
46 private $settings;
47
48 /**
49 * @var Vigilante_Activity_Log|null
50 */
51 private $activity_log;
52
53 /**
54 * @param Vigilante_Settings $settings
55 * @param Vigilante_Activity_Log|null $activity_log
56 */
57 public function __construct( Vigilante_Settings $settings, $activity_log = null ) {
58 $this->settings = $settings;
59 $this->activity_log = $activity_log;
60 }
61
62 /**
63 * Run the category. All checks are fast (no HTTP).
64 *
65 * @param string $phase 'fast' | 'slow' | 'all'.
66 * @return Vigilante_SA_Check_Result[]
67 */
68 public function run( $phase = 'all' ) {
69 if ( 'slow' === $phase ) {
70 return array();
71 }
72
73 $results = array();
74 $results[] = $this->check_php_version();
75 $results[] = $this->check_wp_core_updates();
76 $results[] = $this->check_plugin_updates();
77 $results[] = $this->check_theme_updates();
78 $results[] = $this->check_inactive_plugins();
79 $results[] = $this->check_closed_plugins();
80 $results[] = $this->check_file_permissions();
81 $results[] = $this->check_salts_default();
82 $results[] = $this->check_table_prefix();
83 $results[] = $this->check_admin_username();
84 $results[] = $this->check_admins_without_2fa();
85 $results[] = $this->check_vigilante_modules_off();
86 $results[] = $this->check_activity_log_errors();
87 $results[] = $this->check_file_integrity_status();
88 $results[] = $this->check_audit_alerts_active();
89
90 return $results;
91 }
92
93 private function check_php_version() {
94 $args = array(
95 'id' => 'php_version_eol',
96 'category' => self::SLUG,
97 'max' => 3,
98 'label' => __( 'PHP version support', 'vigilante' ),
99 'fix_link' => '',
100 );
101
102 $branch = Vigilante_SA_Helpers::current_php_branch();
103 $table = Vigilante_SA_Helpers::php_eol_table();
104 $args['data'] = array(
105 'php_branch' => $branch,
106 'php_full' => PHP_VERSION,
107 );
108
109 if ( ! isset( $table[ $branch ] ) ) {
110 $args['detail'] = sprintf(
111 /* translators: %s: PHP branch like 8.1 */
112 __( 'PHP %s is unknown to the built-in EOL table. Verify with your host.', 'vigilante' ),
113 $branch
114 );
115 return Vigilante_SA_Check_Result::warn( $args );
116 }
117
118 $eol_timestamp = strtotime( $table[ $branch ] );
119 $args['data']['eol_date'] = $table[ $branch ];
120
121 if ( time() > $eol_timestamp ) {
122 $args['detail'] = sprintf(
123 /* translators: 1: php branch, 2: EOL date */
124 __( 'PHP %1$s reached end-of-life on %2$s. Upgrade to a supported branch immediately.', 'vigilante' ),
125 $branch,
126 $table[ $branch ]
127 );
128 return Vigilante_SA_Check_Result::fail( $args );
129 }
130
131 $months_left = (int) floor( ( $eol_timestamp - time() ) / ( 30 * DAY_IN_SECONDS ) );
132 $args['data']['months_left'] = $months_left;
133 if ( $months_left <= 6 ) {
134 $args['detail'] = sprintf(
135 /* translators: 1: branch, 2: months */
136 __( 'PHP %1$s reaches end-of-life in roughly %2$d months. Start planning the upgrade.', 'vigilante' ),
137 $branch,
138 $months_left
139 );
140 return Vigilante_SA_Check_Result::warn( $args );
141 }
142
143 $args['detail'] = sprintf(
144 /* translators: 1: branch, 2: EOL date */
145 __( 'PHP %1$s within support window (EOL %2$s).', 'vigilante' ),
146 $branch,
147 $table[ $branch ]
148 );
149 return Vigilante_SA_Check_Result::pass( $args );
150 }
151
152 private function check_wp_core_updates() {
153 $args = array(
154 'id' => 'wp_core_updates',
155 'category' => self::SLUG,
156 'max' => 3,
157 'label' => __( 'WordPress core version', 'vigilante' ),
158 'fix_link' => admin_url( 'update-core.php' ),
159 );
160
161 if ( ! function_exists( 'get_core_updates' ) ) {
162 require_once ABSPATH . 'wp-admin/includes/update.php';
163 }
164 $updates = function_exists( 'get_core_updates' ) ? get_core_updates() : array();
165
166 $has_update = false;
167 $next_version = '';
168 if ( is_array( $updates ) ) {
169 foreach ( $updates as $u ) {
170 if ( isset( $u->response ) && 'upgrade' === $u->response ) {
171 $has_update = true;
172 $next_version = isset( $u->version ) ? $u->version : '';
173 break;
174 }
175 }
176 }
177
178 $args['data'] = array(
179 'current' => get_bloginfo( 'version' ),
180 'next' => $next_version,
181 );
182
183 if ( $has_update ) {
184 $args['detail'] = sprintf(
185 /* translators: 1: current version, 2: new version */
186 __( 'Core can update from %1$s to %2$s. Apply the update now.', 'vigilante' ),
187 get_bloginfo( 'version' ),
188 $next_version
189 );
190 return Vigilante_SA_Check_Result::fail( $args );
191 }
192
193 $args['detail'] = sprintf(
194 /* translators: %s: wp version */
195 __( 'Running WordPress %s — no core update pending.', 'vigilante' ),
196 get_bloginfo( 'version' )
197 );
198 return Vigilante_SA_Check_Result::pass( $args );
199 }
200
201 private function check_plugin_updates() {
202 $args = array(
203 'id' => 'plugin_updates',
204 'category' => self::SLUG,
205 'max' => 2,
206 'label' => __( 'Plugin updates', 'vigilante' ),
207 'fix_link' => admin_url( 'plugins.php?plugin_status=upgrade' ),
208 );
209
210 if ( ! function_exists( 'get_plugin_updates' ) ) {
211 require_once ABSPATH . 'wp-admin/includes/update.php';
212 }
213 $updates = function_exists( 'get_plugin_updates' ) ? get_plugin_updates() : array();
214 $count = is_array( $updates ) ? count( $updates ) : 0;
215 $args['data'] = array( 'count' => $count );
216
217 if ( 0 === $count ) {
218 $args['detail'] = __( 'All active plugins on their latest version.', 'vigilante' );
219 return Vigilante_SA_Check_Result::pass( $args );
220 }
221 if ( $count <= 2 ) {
222 $args['detail'] = sprintf(
223 /* translators: %d: count */
224 _n( '%d plugin has an update available.', '%d plugins have updates available.', $count, 'vigilante' ),
225 $count
226 );
227 return Vigilante_SA_Check_Result::warn( $args );
228 }
229
230 $args['detail'] = sprintf(
231 /* translators: %d: count */
232 _n( '%d plugin is outdated.', '%d plugins are outdated. Apply updates as soon as possible.', $count, 'vigilante' ),
233 $count
234 );
235 return Vigilante_SA_Check_Result::fail( $args );
236 }
237
238 private function check_theme_updates() {
239 $args = array(
240 'id' => 'theme_updates',
241 'category' => self::SLUG,
242 'max' => 1,
243 'label' => __( 'Theme updates', 'vigilante' ),
244 'fix_link' => admin_url( 'themes.php' ),
245 );
246
247 if ( ! function_exists( 'get_theme_updates' ) ) {
248 require_once ABSPATH . 'wp-admin/includes/update.php';
249 }
250 $updates = function_exists( 'get_theme_updates' ) ? get_theme_updates() : array();
251 $count = is_array( $updates ) ? count( $updates ) : 0;
252 $args['data'] = array( 'count' => $count );
253
254 if ( 0 === $count ) {
255 $args['detail'] = __( 'All installed themes on their latest version.', 'vigilante' );
256 return Vigilante_SA_Check_Result::pass( $args );
257 }
258
259 $args['detail'] = sprintf(
260 /* translators: %d: count */
261 _n( '%d theme needs updating.', '%d themes need updating.', $count, 'vigilante' ),
262 $count
263 );
264 return Vigilante_SA_Check_Result::fail( $args );
265 }
266
267 private function check_inactive_plugins() {
268 $args = array(
269 'id' => 'inactive_plugins',
270 'category' => self::SLUG,
271 'max' => 2,
272 'label' => __( 'Inactive plugins', 'vigilante' ),
273 'fix_link' => admin_url( 'plugins.php?plugin_status=inactive' ),
274 );
275
276 if ( ! function_exists( 'get_plugins' ) ) {
277 require_once ABSPATH . 'wp-admin/includes/plugin.php';
278 }
279 // Always read fresh: some caches can stale-serve this option on admin-ajax.
280 wp_cache_delete( 'alloptions', 'options' );
281
282 $plugins_map = (array) get_plugins();
283 $all = array_keys( $plugins_map );
284 $active = (array) get_option( 'active_plugins', array() );
285
286 if ( is_multisite() ) {
287 $network_active = array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) );
288 $active = array_merge( $active, $network_active );
289 }
290
291 $inactive = array_values( array_diff( $all, $active ) );
292 $count = count( $inactive );
293
294 // Build a friendly sample of the first few inactive plugin names.
295 $sample_names = array();
296 foreach ( array_slice( $inactive, 0, 5 ) as $file ) {
297 $sample_names[] = isset( $plugins_map[ $file ]['Name'] ) && $plugins_map[ $file ]['Name']
298 ? $plugins_map[ $file ]['Name']
299 : $file;
300 }
301
302 $args['data'] = array(
303 'count' => $count,
304 'total_known' => count( $all ),
305 'total_active' => count( array_unique( $active ) ),
306 'sample_names' => $sample_names,
307 );
308
309 if ( 0 === $count ) {
310 $args['detail'] = __( 'No inactive plugins installed.', 'vigilante' );
311 return Vigilante_SA_Check_Result::pass( $args );
312 }
313 if ( $count <= 2 ) {
314 $args['detail'] = sprintf(
315 /* translators: 1: count, 2: sample names */
316 _n(
317 '%1$d plugin is installed but inactive (%2$s).',
318 '%1$d plugins are installed but inactive (%2$s).',
319 $count,
320 'vigilante'
321 ),
322 $count,
323 implode( ', ', $sample_names )
324 );
325 return Vigilante_SA_Check_Result::warn( $args );
326 }
327
328 $args['detail'] = sprintf(
329 /* translators: 1: count, 2: sample names */
330 __( '%1$d inactive plugins installed (e.g. %2$s). Delete what you no longer use — inactive code still lives on disk and can be exploited.', 'vigilante' ),
331 $count,
332 implode( ', ', $sample_names )
333 );
334 return Vigilante_SA_Check_Result::fail( $args );
335 }
336
337 /**
338 * Closed + Removed plugins (v2.6.0).
339 *
340 * Reads the cached state map populated by Vigilante_Plugin_Status (daily
341 * cron + Run Scan Now). No extra HTTP call here. Ignored slugs are
342 * filtered out — if the admin chose to silence a slug it stays out of
343 * the score too, mirroring how the rest of Vigilant treats ignored items.
344 */
345 private function check_closed_plugins() {
346 $args = array(
347 'id' => 'closed_plugins',
348 'category' => self::SLUG,
349 'max' => 3,
350 'label' => __( 'Closed or removed plugins', 'vigilante' ),
351 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'file-integrity', 'vigilante-section-fi-last-scan' ),
352 );
353
354 $fi_options = (array) $this->settings->get_section( 'file_integrity' );
355 $check_enabled = ! empty( $fi_options['check_closed_plugins'] );
356
357 if ( ! $check_enabled ) {
358 $args['detail'] = __( 'The "Closed plugins" check is disabled in File Integrity > Scan Scope.', 'vigilante' );
359 return Vigilante_SA_Check_Result::skip( $args );
360 }
361
362 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
363 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
364 }
365 $checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
366 $closed = $checker->get_closed_plugins();
367 $last_check = $checker->get_last_check_time();
368 $ignored_slugs = $checker->get_ignored_slugs();
369
370 $args['data'] = array(
371 'closed_count' => count( $closed ),
372 'ignored_count' => count( $ignored_slugs ),
373 'last_check' => $last_check,
374 );
375
376 if ( 0 === $last_check ) {
377 $args['detail'] = __( 'The daily closed-plugins check has not run yet. It will run automatically within the next 24 hours, or with the next "Run Scan Now".', 'vigilante' );
378 return Vigilante_SA_Check_Result::skip( $args );
379 }
380
381 if ( empty( $closed ) ) {
382 if ( ! empty( $ignored_slugs ) ) {
383 $args['detail'] = sprintf(
384 /* translators: %d: count of ignored slugs */
385 _n(
386 'No active closed plugins. %d slug is on the ignore list and excluded from this check.',
387 'No active closed plugins. %d slugs are on the ignore list and excluded from this check.',
388 count( $ignored_slugs ),
389 'vigilante'
390 ),
391 count( $ignored_slugs )
392 );
393 } else {
394 $args['detail'] = __( 'No installed plugin is currently closed in the WordPress.org repository.', 'vigilante' );
395 }
396 return Vigilante_SA_Check_Result::pass( $args );
397 }
398
399 // Build a friendly sample of names for the detail message.
400 $sample = array();
401 foreach ( array_slice( $closed, 0, 5, true ) as $slug => $entry ) {
402 $sample[] = isset( $entry['name'] ) && $entry['name'] ? $entry['name'] : $slug;
403 }
404
405 $args['detail'] = sprintf(
406 /* translators: 1: count, 2: sample names */
407 _n(
408 '%1$d plugin installed on this site is currently closed or removed in the WordPress.org repository (%2$s). Closures usually indicate malware, security issues, guideline violations or supply chain compromises. Uninstall and replace as soon as possible.',
409 '%1$d plugins installed on this site are currently closed or removed in the WordPress.org repository (%2$s). Closures usually indicate malware, security issues, guideline violations or supply chain compromises. Uninstall and replace as soon as possible.',
410 count( $closed ),
411 'vigilante'
412 ),
413 count( $closed ),
414 implode( ', ', $sample )
415 );
416 return Vigilante_SA_Check_Result::fail( $args );
417 }
418
419 private function check_file_permissions() {
420 $args = array(
421 'id' => 'file_permissions',
422 'category' => self::SLUG,
423 'max' => 2,
424 'label' => __( 'Core file permissions', 'vigilante' ),
425 'fix_link' => '',
426 );
427
428 $wp_config = ABSPATH . 'wp-config.php';
429 $htaccess = ABSPATH . '.htaccess';
430 $issues = array();
431
432 if ( file_exists( $wp_config ) ) {
433 $perm = fileperms( $wp_config ) & 0777;
434 // Anything more permissive than 0644 is worth flagging.
435 if ( $perm & 0022 ) {
436 $issues[] = sprintf( 'wp-config.php: %s', self::octal( $perm ) );
437 }
438 }
439
440 if ( file_exists( $htaccess ) ) {
441 $perm = fileperms( $htaccess ) & 0777;
442 if ( $perm & 0022 ) {
443 $issues[] = sprintf( '.htaccess: %s', self::octal( $perm ) );
444 }
445 }
446
447 $args['data'] = array( 'issues' => $issues );
448
449 if ( empty( $issues ) ) {
450 $args['detail'] = __( 'wp-config.php and .htaccess are not world-writable.', 'vigilante' );
451 return Vigilante_SA_Check_Result::pass( $args );
452 }
453
454 $args['detail'] = sprintf(
455 /* translators: %s: comma-separated list of files with octal perms */
456 __( 'World-writable permissions detected: %s. Ask your host to tighten them.', 'vigilante' ),
457 implode( ', ', $issues )
458 );
459 return Vigilante_SA_Check_Result::fail( $args );
460 }
461
462 private function check_salts_default() {
463 $args = array(
464 'id' => 'salts_default',
465 'category' => self::SLUG,
466 'max' => 2,
467 'label' => __( 'Secret keys (salts)', 'vigilante' ),
468 'fix_link' => '',
469 );
470
471 $keys = array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
472 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' );
473
474 $missing_or_weak = array();
475 foreach ( $keys as $k ) {
476 if ( ! defined( $k ) ) {
477 $missing_or_weak[] = $k;
478 continue;
479 }
480 $val = constant( $k );
481 if ( ! is_string( $val ) || strlen( $val ) < 32 ) {
482 $missing_or_weak[] = $k;
483 continue;
484 }
485 if ( false !== stripos( $val, 'put your unique phrase here' ) ) {
486 $missing_or_weak[] = $k;
487 }
488 }
489
490 $args['data'] = array( 'weak_keys' => $missing_or_weak );
491
492 if ( empty( $missing_or_weak ) ) {
493 $args['detail'] = __( 'All WordPress secret keys are defined and at least 32 characters long.', 'vigilante' );
494 return Vigilante_SA_Check_Result::pass( $args );
495 }
496
497 $args['detail'] = sprintf(
498 /* translators: %s: comma-separated key names */
499 __( 'These secret keys look weak or default: %s. Regenerate them from https://api.wordpress.org/secret-key/1.1/salt/', 'vigilante' ),
500 implode( ', ', $missing_or_weak )
501 );
502 return Vigilante_SA_Check_Result::fail( $args );
503 }
504
505 private function check_table_prefix() {
506 global $wpdb;
507 $args = array(
508 'id' => 'table_prefix',
509 'category' => self::SLUG,
510 'max' => 2,
511 'label' => __( 'Database prefix', 'vigilante' ),
512 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'wp-hardening', 'vigilante-section-hardening-database' ),
513 );
514
515 $prefix = $wpdb ? $wpdb->prefix : 'wp_';
516 $args['data'] = array( 'prefix' => $prefix );
517
518 if ( 'wp_' === $prefix ) {
519 $args['detail'] = __( 'The database prefix is still the default "wp_". Change it to a custom prefix.', 'vigilante' );
520 return Vigilante_SA_Check_Result::fail( $args );
521 }
522
523 $args['detail'] = sprintf(
524 /* translators: %s: custom table prefix */
525 __( 'Using a custom database prefix: %s', 'vigilante' ),
526 $prefix
527 );
528 return Vigilante_SA_Check_Result::pass( $args );
529 }
530
531 private function check_admin_username() {
532 $args = array(
533 'id' => 'admin_username',
534 'category' => self::SLUG,
535 'max' => 2,
536 'label' => __( 'Administrator named "admin"', 'vigilante' ),
537 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'users', 'vigilante-section-users-main' ),
538 );
539
540 $user = get_user_by( 'login', 'admin' );
541 if ( $user instanceof WP_User && in_array( 'administrator', (array) $user->roles, true ) ) {
542 $args['detail'] = __( 'A user named "admin" with administrator role exists. Create a new admin account and delete this one.', 'vigilante' );
543 return Vigilante_SA_Check_Result::fail( $args );
544 }
545
546 $args['detail'] = __( 'No administrator is named "admin".', 'vigilante' );
547 return Vigilante_SA_Check_Result::pass( $args );
548 }
549
550 private function check_admins_without_2fa() {
551 $args = array(
552 'id' => 'admins_without_2fa',
553 'category' => self::SLUG,
554 'max' => 2,
555 'label' => __( 'Administrators with 2FA enrolled', 'vigilante' ),
556 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'login', 'vigilante-section-login-2fa' ),
557 );
558
559 $two_factor = $this->settings->get_option( 'login_security', 'two_factor', array() );
560 if ( empty( $two_factor['enabled'] ) ) {
561 $args['detail'] = __( '2FA is not enabled globally, so no administrator is protected with a second factor.', 'vigilante' );
562 return Vigilante_SA_Check_Result::fail( $args );
563 }
564
565 $admins = get_users(
566 array(
567 'role' => 'administrator',
568 'fields' => array( 'ID', 'user_login' ),
569 )
570 );
571 if ( empty( $admins ) ) {
572 $args['detail'] = __( 'No administrators found (?).', 'vigilante' );
573 return Vigilante_SA_Check_Result::skip( $args );
574 }
575
576 $method = isset( $two_factor['method'] ) ? $two_factor['method'] : 'email';
577 $unenrolled = array();
578 $enrolled_count = 0;
579
580 foreach ( $admins as $admin ) {
581 $enrolled = $this->is_user_enrolled( $admin->ID, $method );
582 if ( $enrolled ) {
583 $enrolled_count++;
584 } else {
585 $unenrolled[] = $admin->user_login;
586 }
587 }
588
589 $total = count( $admins );
590 $args['data'] = array(
591 'total' => $total,
592 'enrolled' => $enrolled_count,
593 'unenrolled' => $unenrolled,
594 'method' => $method,
595 );
596
597 if ( 0 === count( $unenrolled ) ) {
598 $args['detail'] = sprintf(
599 /* translators: %d: number of administrators with 2FA enrolled */
600 _n( '%d administrator has 2FA enrolled.', '%d administrators have 2FA enrolled.', $total, 'vigilante' ),
601 $total
602 );
603 return Vigilante_SA_Check_Result::pass( $args );
604 }
605
606 $args['detail'] = sprintf(
607 /* translators: 1: unenrolled count, 2: total admins, 3: comma-separated logins */
608 __( '%1$d of %2$d administrators do not have 2FA set up: %3$s', 'vigilante' ),
609 count( $unenrolled ),
610 $total,
611 implode( ', ', array_slice( $unenrolled, 0, 5 ) )
612 );
613 return count( $unenrolled ) === $total
614 ? Vigilante_SA_Check_Result::fail( $args )
615 : Vigilante_SA_Check_Result::warn( $args );
616 }
617
618 private function check_vigilante_modules_off() {
619 $args = array(
620 'id' => 'vigilante_modules_off',
621 'category' => self::SLUG,
622 'max' => 2,
623 'label' => __( 'Core Vigilant modules', 'vigilante' ),
624 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'dashboard', 'vigilante-section-dashboard-modules' ),
625 );
626
627 $critical = array( 'firewall', 'login_security', 'file_integrity', 'security_headers' );
628 $off = array();
629 $module_names = array(
630 'firewall' => __( 'Firewall', 'vigilante' ),
631 'login_security' => __( 'Login Security', 'vigilante' ),
632 'file_integrity' => __( 'File Integrity', 'vigilante' ),
633 'security_headers' => __( 'Security Headers', 'vigilante' ),
634 );
635
636 foreach ( $critical as $mod ) {
637 if ( ! $this->settings->is_module_enabled( $mod ) ) {
638 $off[] = isset( $module_names[ $mod ] ) ? $module_names[ $mod ] : $mod;
639 }
640 }
641
642 $args['data'] = array( 'off' => $off );
643
644 if ( empty( $off ) ) {
645 $args['detail'] = __( 'Firewall, Login Security, File Integrity and Security Headers modules all detected as enabled.', 'vigilante' );
646 return Vigilante_SA_Check_Result::pass( $args );
647 }
648
649 $args['detail'] = sprintf(
650 /* translators: %s: comma-separated module names */
651 __( 'Critical modules are disabled: %s. Re-enable them from the Dashboard.', 'vigilante' ),
652 implode( ', ', $off )
653 );
654 return count( $off ) >= 2
655 ? Vigilante_SA_Check_Result::fail( $args )
656 : Vigilante_SA_Check_Result::warn( $args );
657 }
658
659 private function check_audit_alerts_active() {
660 $args = array(
661 'id' => 'audit_alerts_active',
662 'category' => self::SLUG,
663 'max' => 2,
664 'label' => __( 'Audit alerts configured', 'vigilante' ),
665 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'activity-log', 'vigilante-section-audit-alerts' ),
666 );
667
668 // Alerts ride on top of Security Audit; with the module off there is
669 // nothing to alert on, so the check does not apply.
670 if ( ! $this->settings->is_module_enabled( 'activity_log' ) ) {
671 $args['detail'] = __( 'Security Audit is disabled, so audit alerts do not apply. Enable Security Audit to use them.', 'vigilante' );
672 return Vigilante_SA_Check_Result::skip( $args );
673 }
674
675 if ( ! class_exists( 'Vigilante_Audit_Alerts' ) ) {
676 require_once VIGILANTE_INCLUDES_DIR . 'class-audit-alerts.php';
677 }
678
679 $config = (array) $this->settings->get_section( 'audit_alerts' );
680 $immediate = Vigilante_Audit_Alerts::immediate_is_active( $config );
681 $threshold = Vigilante_Audit_Alerts::threshold_is_active( $config );
682
683 $args['data'] = array(
684 'immediate' => $immediate,
685 'threshold' => $threshold,
686 );
687
688 if ( $immediate || $threshold ) {
689 $active = array();
690 if ( $immediate ) {
691 $active[] = __( 'immediate', 'vigilante' );
692 }
693 if ( $threshold ) {
694 $active[] = __( 'threshold', 'vigilante' );
695 }
696 $args['detail'] = sprintf(
697 /* translators: %s: comma-separated list of active alert types */
698 __( 'Audit alerts are active (%s). Important events will reach you by email.', 'vigilante' ),
699 implode( ', ', $active )
700 );
701 return Vigilante_SA_Check_Result::pass( $args );
702 }
703
704 $args['detail'] = __( 'No audit alerts are configured. Turn on immediate or threshold alerts so important events reach you by email.', 'vigilante' );
705 return Vigilante_SA_Check_Result::warn( $args );
706 }
707
708 private function check_activity_log_errors() {
709 $args = array(
710 'id' => 'activity_log_errors',
711 'category' => self::SLUG,
712 'max' => 1,
713 'label' => __( 'Critical activity in the last 24 hours', 'vigilante' ),
714 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'activity-log', 'vigilante-section-audit-recent' ),
715 );
716
717 if ( ! $this->activity_log || ! method_exists( $this->activity_log, 'get_logs_count' ) ) {
718 $args['detail'] = __( 'Activity log service not available for this scan.', 'vigilante' );
719 return Vigilante_SA_Check_Result::skip( $args );
720 }
721
722 $since = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
723 $count_critical = (int) $this->activity_log->get_logs_count(
724 array(
725 'severity' => 'critical',
726 'date_from' => $since,
727 )
728 );
729 $count_high = (int) $this->activity_log->get_logs_count(
730 array(
731 'severity' => 'high',
732 'date_from' => $since,
733 )
734 );
735 $count = $count_critical + $count_high;
736 $args['data'] = array(
737 'count' => $count,
738 'count_critical' => $count_critical,
739 'count_high' => $count_high,
740 );
741
742 if ( 0 === $count ) {
743 $args['detail'] = __( 'No high or critical events in the last 24 hours.', 'vigilante' );
744 return Vigilante_SA_Check_Result::pass( $args );
745 }
746 if ( $count <= 3 ) {
747 $args['detail'] = sprintf(
748 /* translators: %d: count */
749 _n( '%d high-severity event in the last 24 hours.', '%d high-severity events in the last 24 hours.', $count, 'vigilante' ),
750 $count
751 );
752 return Vigilante_SA_Check_Result::warn( $args );
753 }
754
755 $args['detail'] = sprintf(
756 /* translators: %d: count */
757 __( '%d high or critical events in the last 24 hours. Review the audit log.', 'vigilante' ),
758 $count
759 );
760 return Vigilante_SA_Check_Result::fail( $args );
761 }
762
763 private function check_file_integrity_status() {
764 $args = array(
765 'id' => 'file_integrity_status',
766 'category' => self::SLUG,
767 'max' => 1,
768 'label' => __( 'File integrity scan', 'vigilante' ),
769 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'file-integrity', 'vigilante-section-fi-last-scan' ),
770 );
771
772 $last = get_option( 'vigilante_last_integrity_results' );
773 if ( ! is_array( $last ) ) {
774 $args['detail'] = __( 'No file integrity scan recorded yet. Run one from the File Integrity tab.', 'vigilante' );
775 return Vigilante_SA_Check_Result::skip( $args );
776 }
777
778 $suspicious = isset( $last['suspicious'] ) ? count( (array) $last['suspicious'] ) : 0;
779 $modified = isset( $last['modified'] ) ? count( (array) $last['modified'] ) : 0;
780
781 $args['data'] = array(
782 'suspicious' => $suspicious,
783 'modified' => $modified,
784 );
785
786 if ( 0 === $suspicious && 0 === $modified ) {
787 $args['detail'] = __( 'Last file integrity scan returned no suspicious or modified files.', 'vigilante' );
788 return Vigilante_SA_Check_Result::pass( $args );
789 }
790
791 if ( $suspicious > 0 ) {
792 $args['detail'] = sprintf(
793 /* translators: %d: count */
794 _n( '%d suspicious file flagged by the last integrity scan.', '%d suspicious files flagged by the last integrity scan.', $suspicious, 'vigilante' ),
795 $suspicious
796 );
797 return Vigilante_SA_Check_Result::fail( $args );
798 }
799
800 $args['detail'] = sprintf(
801 /* translators: %d: count */
802 _n( '%d modified file recorded by the last integrity scan — review in File Integrity.', '%d modified files recorded by the last integrity scan.', $modified, 'vigilante' ),
803 $modified
804 );
805 return Vigilante_SA_Check_Result::warn( $args );
806 }
807
808 /**
809 * Whether the given user is enrolled in 2FA for the active method.
810 *
811 * @param int $user_id User ID.
812 * @param string $method 'email' | 'totp'.
813 * @return bool
814 */
815 private function is_user_enrolled( $user_id, $method ) {
816 if ( 'totp' === $method ) {
817 // TOTP stores enrollment in the database via Vigilante_Database->get_totp_data().
818 if ( class_exists( 'Vigilante_Database' ) ) {
819 $database = new Vigilante_Database();
820 if ( method_exists( $database, 'get_totp_data' ) ) {
821 $data = $database->get_totp_data( $user_id );
822 return is_array( $data ) && ! empty( $data['is_configured'] );
823 }
824 }
825 return false;
826 }
827
828 // For email 2FA, enrollment is implicit once the user has a verified email
829 // and the global setting is on. We conservatively consider any admin enrolled
830 // because email codes will be delivered on login.
831 $user = get_userdata( $user_id );
832 return $user && ! empty( $user->user_email ) && is_email( $user->user_email );
833 }
834
835 /**
836 * Format octal permissions like "644".
837 */
838 private static function octal( $perm ) {
839 return substr( sprintf( '%o', $perm ), -3 );
840 }
841 }
842