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

1,088 lines 45.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 — Internal-exclusive category (40 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 (10) ← 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 // The daily check belongs to File Integrity and only runs with that
361 // module on (vigilante.php). With it off, whatever is stored is the
362 // answer of the last day it ran.
363 if ( ! $this->settings->is_module_enabled( 'file_integrity' ) ) {
364 $args['detail'] = Vigilante_SA_Helpers::module_idle_detail( $this->settings, 'file_integrity' );
365 return Vigilante_SA_Check_Result::fail( $args );
366 }
367
368 // A protection somebody switched off is not one that could not be
369 // measured: it scores nothing, and it stays in the total.
370 if ( ! $check_enabled ) {
371 $args['detail'] = __( 'The "Closed plugins" check is disabled in File Integrity > Scan Scope.', 'vigilante' );
372 return Vigilante_SA_Check_Result::fail( $args );
373 }
374
375 if ( ! class_exists( 'Vigilante_Plugin_Status' ) ) {
376 require_once VIGILANTE_INCLUDES_DIR . 'class-plugin-status.php';
377 }
378 $checker = new Vigilante_Plugin_Status( $this->settings, $this->activity_log );
379 $closed = $checker->get_closed_plugins();
380 $last_check = $checker->get_last_check_time();
381 $ignored_slugs = $checker->get_ignored_slugs();
382
383 $args['data'] = array(
384 'closed_count' => count( $closed ),
385 'ignored_count' => count( $ignored_slugs ),
386 'last_check' => $last_check,
387 );
388
389 if ( 0 === $last_check ) {
390 $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' );
391 return Vigilante_SA_Check_Result::skip( $args );
392 }
393
394 if ( empty( $closed ) ) {
395 if ( ! empty( $ignored_slugs ) ) {
396 $args['detail'] = sprintf(
397 /* translators: %d: count of ignored slugs */
398 _n(
399 'No active closed plugins. %d slug is on the ignore list and excluded from this check.',
400 'No active closed plugins. %d slugs are on the ignore list and excluded from this check.',
401 count( $ignored_slugs ),
402 'vigilante'
403 ),
404 count( $ignored_slugs )
405 );
406 } else {
407 $args['detail'] = __( 'No installed plugin is currently closed in the WordPress.org repository.', 'vigilante' );
408 }
409 return Vigilante_SA_Check_Result::pass( $args );
410 }
411
412 // Build a friendly sample of names for the detail message.
413 $sample = array();
414 foreach ( array_slice( $closed, 0, 5, true ) as $slug => $entry ) {
415 $sample[] = isset( $entry['name'] ) && $entry['name'] ? $entry['name'] : $slug;
416 }
417
418 $args['detail'] = sprintf(
419 /* translators: 1: count, 2: sample names */
420 _n(
421 '%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.',
422 '%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.',
423 count( $closed ),
424 'vigilante'
425 ),
426 count( $closed ),
427 implode( ', ', $sample )
428 );
429 return Vigilante_SA_Check_Result::fail( $args );
430 }
431
432 /**
433 * Vigilant self-integrity state (self-protection, 3.0.0).
434 *
435 * Reads the cached vigilante_self_integrity_state option written by
436 * Vigilante_Self_Integrity: no filesystem hashing and no HTTP here, so
437 * the check is fast-phase safe. The wording is the shared guidance
438 * catalogue, so this check, the File Integrity box, the audit details and
439 * the alert email say the same thing about the same finding.
440 *
441 * It is the heaviest single check of the analyzer (10 points) and a
442 * critical result caps the whole score in build_report(): every other
443 * result is computed by the same code whose files were changed.
444 */
445 private function check_self_integrity() {
446 $args = array(
447 'id' => 'self_integrity',
448 'category' => self::SLUG,
449 'max' => 10,
450 'label' => __( 'Vigilant self-protection', 'vigilante' ),
451 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'file-integrity', 'vigilante-section-fi-self' ),
452 );
453
454 $enabled = Vigilante_Self_Integrity::is_on();
455 $state = Vigilante_Self_Integrity::display_state();
456 $tone = Vigilante_Self_Integrity::tone( $state, $enabled );
457 $findings = Vigilante_Self_Integrity::state_findings( $state, $enabled );
458 $files = isset( $state['files_checked'] ) ? (int) $state['files_checked'] : 0;
459 $anchors = ( isset( $state['anchors'] ) && is_array( $state['anchors'] ) ) ? $state['anchors'] : array();
460
461 // What decides the points: tasks that Vigilant had to schedule again,
462 // while that is only a warning, do not take any away. The files are as
463 // verified as they were, and that is what these ten points are for.
464 // This file is loaded when the analyzer is created, and the class it
465 // asks is loaded with the plugin: in the cron run in which WordPress
466 // updates Vigilant, the class in memory is the one of the version
467 // before, which may not have the method yet.
468 $score_tone = method_exists( 'Vigilante_Self_Integrity', 'score_tone' ) ? Vigilante_Self_Integrity::score_tone( $state, $enabled ) : $tone;
469
470 $args['data'] = array(
471 'status' => isset( $state['last_status'] ) ? (string) $state['last_status'] : '',
472 'tone' => $tone,
473 'score_tone' => $score_tone,
474 'files_checked' => $files,
475 'anchors' => count( array_filter( $anchors ) ),
476 'findings' => count( $findings ),
477 'last_check' => isset( $state['last_check'] ) ? (int) $state['last_check'] : 0,
478 );
479
480 // Worst finding first: it is the one that decides the tone, so it is
481 // the one to explain.
482 $worst = null;
483 foreach ( $findings as $finding ) {
484 $severity = isset( $finding['severity'] ) ? (string) $finding['severity'] : '';
485 if ( 'critical' === $severity ) {
486 $worst = $finding;
487 break;
488 }
489 if ( 'warning' === $severity && null === $worst ) {
490 $worst = $finding;
491 }
492 }
493
494 if ( null !== $worst ) {
495 $guidance = Vigilante_Self_Integrity_Guidance::for_finding( $worst );
496 } else {
497 $guidance = Vigilante_Self_Integrity_Guidance::for_status(
498 array(
499 'status' => isset( $state['last_status'] ) ? $state['last_status'] : '',
500 'files' => $files,
501 'anchors' => $anchors,
502 'enabled' => $enabled,
503 'has_run' => ! empty( $state['last_check'] ),
504 )
505 );
506 }
507
508 $detail = $guidance['title'] . '. ' . $guidance['meaning'];
509 if ( ! empty( $guidance['steps'] ) ) {
510 $detail .= ' ' . $guidance['steps'][0];
511 }
512 $count = count( $findings );
513 if ( $count > 1 ) {
514 $detail .= ' ' . sprintf(
515 /* translators: %d: number of findings about Vigilant own files */
516 _n(
517 '%d finding in total: File Integrity lists every one, with what it means and what to do.',
518 '%d findings in total: File Integrity lists every one, with what each one means and what to do.',
519 $count,
520 'vigilante'
521 ),
522 $count
523 );
524 }
525 $args['detail'] = $detail;
526
527 if ( 'none' === $tone ) {
528 return Vigilante_SA_Check_Result::skip( $args );
529 }
530 // Switched off by code counts as a failure, not as a check that did not
531 // apply: somebody had to write that filter.
532 if ( 'critical' === $tone || 'off' === $tone ) {
533 return Vigilante_SA_Check_Result::fail( $args );
534 }
535 if ( 'warning' === $score_tone ) {
536 return Vigilante_SA_Check_Result::warn( $args );
537 }
538 return Vigilante_SA_Check_Result::pass( $args );
539 }
540
541 private function check_file_permissions() {
542 $args = array(
543 'id' => 'file_permissions',
544 'category' => self::SLUG,
545 'max' => 2,
546 'label' => __( 'Core file permissions', 'vigilante' ),
547 'fix_link' => '',
548 );
549
550 // WordPress also loads wp-config.php from the directory above its own
551 // when that directory is not another installation (wp-load.php). Until
552 // 3.0.4 only ABSPATH was looked at, and a site set up that way passed
553 // without its file having been read.
554 $wp_config = ABSPATH . 'wp-config.php';
555 if ( ! file_exists( $wp_config ) ) {
556 $above = dirname( ABSPATH );
557 $wp_config = ( @file_exists( $above . '/wp-config.php' ) && ! @file_exists( $above . '/wp-settings.php' ) ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- open_basedir may forbid the directory above; same call and same silencing as wp-load.php.
558 ? $above . '/wp-config.php'
559 : '';
560 }
561
562 $htaccess = ABSPATH . '.htaccess';
563 $issues = array();
564 $config_perm = ( '' !== $wp_config ) ? @fileperms( $wp_config ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A file that cannot be read is reported below, not as a PHP warning.
565
566 if ( false === $config_perm ) {
567 $args['detail'] = __( 'wp-config.php could not be found or read from here, so its permissions could not be checked. Not scored.', 'vigilante' );
568 return Vigilante_SA_Check_Result::skip( $args );
569 }
570
571 // Anything more permissive than 0644 is worth flagging.
572 if ( ( $config_perm & 0777 ) & 0022 ) {
573 $issues[] = sprintf( 'wp-config.php: %s', self::octal( $config_perm & 0777 ) );
574 }
575
576 if ( file_exists( $htaccess ) ) {
577 $perm = fileperms( $htaccess ) & 0777;
578 if ( $perm & 0022 ) {
579 $issues[] = sprintf( '.htaccess: %s', self::octal( $perm ) );
580 }
581 }
582
583 $args['data'] = array( 'issues' => $issues );
584
585 if ( empty( $issues ) ) {
586 $args['detail'] = __( 'wp-config.php and .htaccess are not world-writable.', 'vigilante' );
587 return Vigilante_SA_Check_Result::pass( $args );
588 }
589
590 $args['detail'] = sprintf(
591 /* translators: %s: comma-separated list of files with octal perms */
592 __( 'World-writable permissions detected: %s. Ask your host to tighten them.', 'vigilante' ),
593 implode( ', ', $issues )
594 );
595 return Vigilante_SA_Check_Result::fail( $args );
596 }
597
598 private function check_salts_default() {
599 $args = array(
600 'id' => 'salts_default',
601 'category' => self::SLUG,
602 'max' => 2,
603 'label' => __( 'Secret keys (salts)', 'vigilante' ),
604 'fix_link' => '',
605 );
606
607 $keys = array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
608 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' );
609
610 $missing_or_weak = array();
611 foreach ( $keys as $k ) {
612 if ( ! defined( $k ) ) {
613 $missing_or_weak[] = $k;
614 continue;
615 }
616 $val = constant( $k );
617 if ( ! is_string( $val ) || strlen( $val ) < 32 ) {
618 $missing_or_weak[] = $k;
619 continue;
620 }
621 if ( false !== stripos( $val, 'put your unique phrase here' ) ) {
622 $missing_or_weak[] = $k;
623 }
624 }
625
626 $args['data'] = array( 'weak_keys' => $missing_or_weak );
627
628 if ( empty( $missing_or_weak ) ) {
629 $args['detail'] = __( 'All WordPress secret keys are defined and at least 32 characters long.', 'vigilante' );
630 return Vigilante_SA_Check_Result::pass( $args );
631 }
632
633 $args['detail'] = sprintf(
634 /* translators: %s: comma-separated key names */
635 __( 'These secret keys look weak or default: %s. Regenerate them from https://api.wordpress.org/secret-key/1.1/salt/', 'vigilante' ),
636 implode( ', ', $missing_or_weak )
637 );
638 return Vigilante_SA_Check_Result::fail( $args );
639 }
640
641 private function check_table_prefix() {
642 global $wpdb;
643 $args = array(
644 'id' => 'table_prefix',
645 'category' => self::SLUG,
646 'max' => 2,
647 'label' => __( 'Database prefix', 'vigilante' ),
648 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'wp-hardening', 'vigilante-section-hardening-database' ),
649 );
650
651 $prefix = $wpdb ? $wpdb->prefix : 'wp_';
652 $args['data'] = array( 'prefix' => $prefix );
653
654 if ( 'wp_' === $prefix ) {
655 $args['detail'] = __( 'The database prefix is still the default "wp_". Change it to a custom prefix.', 'vigilante' );
656 return Vigilante_SA_Check_Result::fail( $args );
657 }
658
659 $args['detail'] = sprintf(
660 /* translators: %s: custom table prefix */
661 __( 'Using a custom database prefix: %s', 'vigilante' ),
662 $prefix
663 );
664 return Vigilante_SA_Check_Result::pass( $args );
665 }
666
667 private function check_admin_username() {
668 $args = array(
669 'id' => 'admin_username',
670 'category' => self::SLUG,
671 'max' => 2,
672 'label' => __( 'Administrator named "admin"', 'vigilante' ),
673 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'users', 'vigilante-section-users-password' ),
674 );
675
676 $user = get_user_by( 'login', 'admin' );
677 if ( $user instanceof WP_User && in_array( 'administrator', (array) $user->roles, true ) ) {
678 $args['detail'] = __( 'A user named "admin" with administrator role exists. Create a new admin account and delete this one.', 'vigilante' );
679 return Vigilante_SA_Check_Result::fail( $args );
680 }
681
682 $args['detail'] = __( 'No administrator is named "admin".', 'vigilante' );
683 return Vigilante_SA_Check_Result::pass( $args );
684 }
685
686 private function check_admins_without_2fa() {
687 $args = array(
688 'id' => 'admins_without_2fa',
689 'category' => self::SLUG,
690 'max' => 2,
691 'label' => __( 'Administrators with 2FA enrolled', 'vigilante' ),
692 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'login', 'vigilante-section-login-2fa' ),
693 );
694
695 $two_factor = $this->settings->get_option( 'login_security', 'two_factor', array() );
696
697 // On a network the second factor is enforced on every site and the
698 // requirement is the union of what each site of the account asks for
699 // (Vigilante_Settings::two_factor_required_for()), so this site's own
700 // switch does not settle it. On a single site it does, together with
701 // the Login Security module that loads the two classes.
702 if ( ! is_multisite() ) {
703 if ( empty( $two_factor['enabled'] ) ) {
704 $args['detail'] = __( '2FA is not enabled globally, so no administrator is protected with a second factor.', 'vigilante' );
705 return Vigilante_SA_Check_Result::fail( $args );
706 }
707
708 if ( ! $this->settings->is_module_enabled( 'login_security' ) ) {
709 $args['detail'] = Vigilante_SA_Helpers::module_off_detail( $this->settings, 'login_security' );
710 return Vigilante_SA_Check_Result::fail( $args );
711 }
712 }
713
714 $admins = get_users(
715 array(
716 'role' => 'administrator',
717 'fields' => array( 'ID', 'user_login' ),
718 )
719 );
720 if ( empty( $admins ) ) {
721 $args['detail'] = __( 'No administrators found (?).', 'vigilante' );
722 return Vigilante_SA_Check_Result::skip( $args );
723 }
724
725 $method = isset( $two_factor['method'] ) ? $two_factor['method'] : 'email';
726 $unenrolled = array();
727 $not_asked = 0;
728 $enrolled_count = 0;
729
730 foreach ( $admins as $admin ) {
731 // Asked the way the login asks: the enforced roles and the excluded
732 // users decide who gets a second factor at all. Until 3.0.4 an
733 // administrator left out by either counted here as protected.
734 $account = get_userdata( $admin->ID );
735
736 if ( ! $account || ! Vigilante_Settings::two_factor_required_for( $account ) ) {
737 $not_asked++;
738 $unenrolled[] = $admin->user_login;
739 continue;
740 }
741
742 $methods = Vigilante_Settings::two_factor_methods_for( $account );
743 $enrolled = false;
744 foreach ( ( empty( $methods ) ? array( $method ) : $methods ) as $asked ) {
745 if ( $this->is_user_enrolled( $admin->ID, $asked ) ) {
746 $enrolled = true;
747 break;
748 }
749 }
750
751 if ( $enrolled ) {
752 $enrolled_count++;
753 } else {
754 $unenrolled[] = $admin->user_login;
755 }
756 }
757
758 $total = count( $admins );
759 $args['data'] = array(
760 'total' => $total,
761 'enrolled' => $enrolled_count,
762 'unenrolled' => $unenrolled,
763 'not_asked' => $not_asked,
764 'method' => $method,
765 );
766
767 if ( 0 === count( $unenrolled ) ) {
768 $args['detail'] = sprintf(
769 /* translators: %d: number of administrators with 2FA enrolled */
770 _n( '%d administrator has 2FA enrolled.', '%d administrators have 2FA enrolled.', $total, 'vigilante' ),
771 $total
772 );
773 return Vigilante_SA_Check_Result::pass( $args );
774 }
775
776 $args['detail'] = sprintf(
777 /* translators: 1: unenrolled count, 2: total admins, 3: comma-separated logins */
778 __( '%1$d of %2$d administrators do not have 2FA set up: %3$s', 'vigilante' ),
779 count( $unenrolled ),
780 $total,
781 implode( ', ', array_slice( $unenrolled, 0, 5 ) )
782 );
783
784 if ( $not_asked > 0 ) {
785 $args['detail'] .= ' ' . sprintf(
786 /* translators: %d: number of administrator accounts */
787 _n(
788 '%d of them is not asked for a second factor at all: the role is not among the enforced roles, or the account is in the excluded users.',
789 '%d of them are not asked for a second factor at all: the role is not among the enforced roles, or the account is in the excluded users.',
790 $not_asked,
791 'vigilante'
792 ),
793 $not_asked
794 );
795 }
796
797 return count( $unenrolled ) === $total
798 ? Vigilante_SA_Check_Result::fail( $args )
799 : Vigilante_SA_Check_Result::warn( $args );
800 }
801
802 private function check_vigilante_modules_off() {
803 $args = array(
804 'id' => 'vigilante_modules_off',
805 'category' => self::SLUG,
806 'max' => 2,
807 'label' => __( 'Core Vigilant modules', 'vigilante' ),
808 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'dashboard', 'vigilante-section-dashboard-modules' ),
809 );
810
811 $critical = array( 'firewall', 'login_security', 'file_integrity', 'security_headers' );
812 $off = array();
813 $module_names = array(
814 'firewall' => __( 'Firewall', 'vigilante' ),
815 'login_security' => __( 'Login Security', 'vigilante' ),
816 'file_integrity' => __( 'File Integrity', 'vigilante' ),
817 'security_headers' => __( 'Security Headers', 'vigilante' ),
818 );
819
820 foreach ( $critical as $mod ) {
821 if ( ! $this->settings->is_module_enabled( $mod ) ) {
822 $off[] = isset( $module_names[ $mod ] ) ? $module_names[ $mod ] : $mod;
823 }
824 }
825
826 $args['data'] = array( 'off' => $off );
827
828 if ( empty( $off ) ) {
829 $args['detail'] = __( 'Firewall, Login Security, File Integrity and Security Headers modules all detected as enabled.', 'vigilante' );
830 return Vigilante_SA_Check_Result::pass( $args );
831 }
832
833 $args['detail'] = sprintf(
834 /* translators: %s: comma-separated module names */
835 __( 'Critical modules are disabled: %s. Re-enable them from the Dashboard.', 'vigilante' ),
836 implode( ', ', $off )
837 );
838 return count( $off ) >= 2
839 ? Vigilante_SA_Check_Result::fail( $args )
840 : Vigilante_SA_Check_Result::warn( $args );
841 }
842
843 private function check_audit_alerts_active() {
844 $args = array(
845 'id' => 'audit_alerts_active',
846 'category' => self::SLUG,
847 'max' => 2,
848 'label' => __( 'Audit alerts configured', 'vigilante' ),
849 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'activity-log', 'vigilante-section-audit-alerts' ),
850 );
851
852 // Alerts ride on top of Security Audit; with the module off there is
853 // nothing to alert on. That is a protection switched off, not a check
854 // that could not be measured: it scores nothing and stays in the
855 // total, or switching the module off would raise the score.
856 if ( ! $this->settings->is_module_enabled( 'activity_log' ) ) {
857 $args['detail'] = __( 'Security Audit is disabled, so audit alerts do not apply. Enable Security Audit to use them.', 'vigilante' );
858 return Vigilante_SA_Check_Result::fail( $args );
859 }
860
861 if ( ! class_exists( 'Vigilante_Audit_Alerts' ) ) {
862 require_once VIGILANTE_INCLUDES_DIR . 'class-audit-alerts.php';
863 }
864
865 $config = (array) $this->settings->get_section( 'audit_alerts' );
866 $immediate = Vigilante_Audit_Alerts::immediate_is_active( $config );
867 $threshold = Vigilante_Audit_Alerts::threshold_is_active( $config );
868
869 $args['data'] = array(
870 'immediate' => $immediate,
871 'threshold' => $threshold,
872 );
873
874 if ( $immediate || $threshold ) {
875 $active = array();
876 if ( $immediate ) {
877 $active[] = __( 'immediate', 'vigilante' );
878 }
879 if ( $threshold ) {
880 $active[] = __( 'threshold', 'vigilante' );
881 }
882 $args['detail'] = sprintf(
883 /* translators: %s: comma-separated list of active alert types */
884 __( 'Audit alerts are active (%s). Important events will reach you by email.', 'vigilante' ),
885 implode( ', ', $active )
886 );
887 return Vigilante_SA_Check_Result::pass( $args );
888 }
889
890 $args['detail'] = __( 'No audit alerts are configured. Turn on immediate or threshold alerts so important events reach you by email.', 'vigilante' );
891 return Vigilante_SA_Check_Result::warn( $args );
892 }
893
894 private function check_activity_log_errors() {
895 $args = array(
896 'id' => 'activity_log_errors',
897 'category' => self::SLUG,
898 'max' => 1,
899 'label' => __( 'Critical activity in the last 24 hours', 'vigilante' ),
900 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'activity-log', 'vigilante-section-audit-recent' ),
901 );
902
903 // With Security Audit off nothing is being recorded, so an empty day
904 // is not a quiet one. Asked before the service below: off is a
905 // failure whether or not this scan was handed the log.
906 if ( ! $this->settings->is_module_enabled( 'activity_log' ) ) {
907 $args['detail'] = Vigilante_SA_Helpers::module_idle_detail( $this->settings, 'activity_log' );
908 return Vigilante_SA_Check_Result::fail( $args );
909 }
910
911 if ( ! $this->activity_log || ! method_exists( $this->activity_log, 'get_logs_count' ) ) {
912 $args['detail'] = __( 'Activity log service not available for this scan.', 'vigilante' );
913 return Vigilante_SA_Check_Result::skip( $args );
914 }
915
916 // Only `critical`: the log has no `high` severity, and the query that
917 // asked for it until 3.0.4 always came back with zero.
918 $since = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
919 $count = (int) $this->activity_log->get_logs_count(
920 array(
921 'severity' => 'critical',
922 'date_from' => $since,
923 )
924 );
925 $args['data'] = array(
926 'count' => $count,
927 'count_critical' => $count,
928 );
929
930 if ( 0 === $count ) {
931 $args['detail'] = __( 'No high or critical events in the last 24 hours.', 'vigilante' );
932 return Vigilante_SA_Check_Result::pass( $args );
933 }
934 if ( $count <= 3 ) {
935 $args['detail'] = sprintf(
936 /* translators: %d: count */
937 _n( '%d high-severity event in the last 24 hours.', '%d high-severity events in the last 24 hours.', $count, 'vigilante' ),
938 $count
939 );
940 return Vigilante_SA_Check_Result::warn( $args );
941 }
942
943 $args['detail'] = sprintf(
944 /* translators: %d: count */
945 __( '%d high or critical events in the last 24 hours. Review the audit log.', 'vigilante' ),
946 $count
947 );
948 return Vigilante_SA_Check_Result::fail( $args );
949 }
950
951 private function check_file_integrity_status() {
952 $args = array(
953 'id' => 'file_integrity_status',
954 'category' => self::SLUG,
955 'max' => 1,
956 'label' => __( 'File integrity scan', 'vigilante' ),
957 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'file-integrity', 'vigilante-section-fi-last-scan' ),
958 );
959
960 // With the module off no scan runs, and the result of the last one
961 // that did stays stored: until 3.0.4 it went on passing here.
962 if ( ! $this->settings->is_module_enabled( 'file_integrity' ) ) {
963 $args['detail'] = Vigilante_SA_Helpers::module_idle_detail( $this->settings, 'file_integrity' );
964 return Vigilante_SA_Check_Result::fail( $args );
965 }
966
967 $last = get_option( 'vigilante_last_integrity_results' );
968 if ( ! is_array( $last ) ) {
969 $args['detail'] = __( 'No file integrity scan recorded yet. Run one from the File Integrity tab.', 'vigilante' );
970 return Vigilante_SA_Check_Result::skip( $args );
971 }
972
973 $suspicious = isset( $last['suspicious'] ) ? count( (array) $last['suspicious'] ) : 0;
974 $modified = isset( $last['modified'] ) ? count( (array) $last['modified'] ) : 0;
975
976 $args['data'] = array(
977 'suspicious' => $suspicious,
978 'modified' => $modified,
979 );
980
981 // A scan that could not check everything is not a clean bill: what
982 // it did not reach was not looked at. Until now a partial scan with
983 // nothing found passed here like a complete one.
984 $partial = Vigilante_File_Integrity::is_partial( $last );
985
986 $args['data']['partial'] = $partial;
987
988 // A `new` of the scanner is only a `new`: no hooks, no scan.
989 $scanner = new Vigilante_File_Integrity( $this->settings );
990 $excluded_code = $scanner->excluded_code_folders();
991
992 $args['data']['excluded_code'] = count( $excluded_code );
993
994 if ( 0 === $suspicious && 0 === $modified ) {
995 if ( $partial ) {
996 $args['detail'] = __( 'The last file integrity scan found nothing in what it checked, but it could not check everything. File Integrity lists what was left out.', 'vigilante' );
997 return Vigilante_SA_Check_Result::warn( $args );
998 }
999
1000 // Nor is a result that is older than the schedule allows: a scan
1001 // the server stops halfway stores nothing, one that WordPress
1002 // never starts does not either, and the last one that did finish
1003 // stays as "the last scan" for as long as that goes on.
1004 $last_scan = (int) get_option( 'vigilante_last_integrity_scan', 0 );
1005 $frequency = (string) $this->settings->get_option( 'file_integrity', 'scan_frequency', 'daily' );
1006
1007 if ( $this->settings->is_module_enabled( 'file_integrity' ) && Vigilante_File_Integrity::is_scan_overdue( $last_scan, $frequency ) ) {
1008 $args['detail'] = sprintf(
1009 /* translators: %s: How long ago, such as "5 days" */
1010 __( 'The last file integrity scan that finished is from %s ago, and scans are scheduled. Either the scheduled tasks of WordPress are not running or the scans are not finishing: run one from File Integrity.', 'vigilante' ),
1011 human_time_diff( $last_scan )
1012 );
1013 return Vigilante_SA_Check_Result::warn( $args );
1014 }
1015
1016 // Nor is a scan that Excluded Paths keeps out of whole folders of
1017 // code: it finishes, finds nothing, and never looked there.
1018 if ( ! empty( $excluded_code ) ) {
1019 $args['detail'] = sprintf(
1020 /* translators: %s: Comma separated list of folders */
1021 __( 'The last file integrity scan found nothing, but Excluded Paths holds whole folders of plugins or themes, or the uploads folder, and no scan looks inside them: %s.', 'vigilante' ),
1022 implode( ', ', $excluded_code )
1023 );
1024 return Vigilante_SA_Check_Result::warn( $args );
1025 }
1026
1027 $args['detail'] = __( 'Last file integrity scan returned no suspicious or modified files.', 'vigilante' );
1028 return Vigilante_SA_Check_Result::pass( $args );
1029 }
1030
1031 $partial_note = $partial ? ' ' . __( 'That scan could not check everything: File Integrity lists what was left out.', 'vigilante' ) : '';
1032
1033 if ( ! empty( $excluded_code ) ) {
1034 $partial_note .= ' ' . __( 'Whole folders of plugins or themes, or the uploads folder, are in Excluded Paths, and no scan looks inside them.', 'vigilante' );
1035 }
1036
1037 if ( $suspicious > 0 ) {
1038 $args['detail'] = sprintf(
1039 /* translators: %d: count */
1040 _n( '%d suspicious file flagged by the last integrity scan.', '%d suspicious files flagged by the last integrity scan.', $suspicious, 'vigilante' ),
1041 $suspicious
1042 ) . $partial_note;
1043 return Vigilante_SA_Check_Result::fail( $args );
1044 }
1045
1046 $args['detail'] = sprintf(
1047 /* translators: %d: count */
1048 _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' ),
1049 $modified
1050 ) . $partial_note;
1051 return Vigilante_SA_Check_Result::warn( $args );
1052 }
1053
1054 /**
1055 * Whether the given user is enrolled in 2FA for the active method.
1056 *
1057 * @param int $user_id User ID.
1058 * @param string $method 'email' | 'totp'.
1059 * @return bool
1060 */
1061 private function is_user_enrolled( $user_id, $method ) {
1062 if ( 'totp' === $method ) {
1063 // TOTP stores enrollment in the database via Vigilante_Database->get_totp_data().
1064 if ( class_exists( 'Vigilante_Database' ) ) {
1065 $database = new Vigilante_Database();
1066 if ( method_exists( $database, 'get_totp_data' ) ) {
1067 $data = $database->get_totp_data( $user_id );
1068 return is_array( $data ) && ! empty( $data['is_configured'] );
1069 }
1070 }
1071 return false;
1072 }
1073
1074 // For email 2FA, enrollment is implicit once the user has a verified email
1075 // and the global setting is on. We conservatively consider any admin enrolled
1076 // because email codes will be delivered on login.
1077 $user = get_userdata( $user_id );
1078 return $user && ! empty( $user->user_email ) && is_email( $user->user_email );
1079 }
1080
1081 /**
1082 * Format octal permissions like "644".
1083 */
1084 private static function octal( $perm ) {
1085 return substr( sprintf( '%o', $perm ), -3 );
1086 }
1087 }
1088