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 / security-analyzer / class-sa-category-internal.php

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

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