PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.5
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-access.php

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

255 lines 9.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Security Analyzer — Access/Auth category (20 pts).
4 *
5 * Checks: wp_login_path (5), xmlrpc (5), wp_admin_direct (2),
6 * rest_users_me (2), login_rate_limit (3), two_factor_enabled (3).
7 *
8 * @package Vigilante
9 * @since 2.1.0
10 */
11
12 // Prevent direct access.
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Authentication and access-control checks.
19 */
20 class Vigilante_SA_Category_Access {
21
22 const SLUG = 'access';
23
24 /**
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 public function __construct( Vigilante_Settings $settings ) {
30 $this->settings = $settings;
31 }
32
33 /**
34 * Run the category.
35 *
36 * @param string $phase 'fast' | 'slow' | 'all'.
37 * @return Vigilante_SA_Check_Result[]
38 */
39 public function run( $phase = 'all' ) {
40 $results = array();
41
42 if ( 'fast' === $phase || 'all' === $phase ) {
43 $results[] = $this->check_custom_login_url();
44 $results[] = $this->check_xmlrpc_setting();
45 $results[] = $this->check_login_rate_limit();
46 $results[] = $this->check_two_factor_enabled();
47 }
48
49 if ( 'slow' === $phase || 'all' === $phase ) {
50 $results[] = $this->check_wp_admin_direct();
51 $results[] = $this->check_rest_users_me();
52 }
53
54 return $results;
55 }
56
57 private function check_custom_login_url() {
58 $args = array(
59 'id' => 'wp_login_path',
60 'category' => self::SLUG,
61 'max' => 5,
62 'label' => __( 'Custom login URL', 'vigilante' ),
63 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'login', 'field-custom-login-url' ),
64 );
65
66 $url = trim( (string) $this->settings->get_option( 'login_security', 'custom_login_url', '' ) );
67 if ( '' === $url ) {
68 $args['detail'] = __( 'The login page is still the default /wp-login.php — a known bot target.', 'vigilante' );
69 return Vigilante_SA_Check_Result::fail( $args );
70 }
71
72 $args['detail'] = sprintf(
73 /* translators: %s: slug */
74 __( 'Custom login URL set to /%s. Remember to bookmark it.', 'vigilante' ),
75 $url
76 );
77 return Vigilante_SA_Check_Result::pass( $args );
78 }
79
80 private function check_xmlrpc_setting() {
81 $args = array(
82 'id' => 'xmlrpc',
83 'category' => self::SLUG,
84 'max' => 5,
85 'label' => __( 'XML-RPC status', 'vigilante' ),
86 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'login', 'field-disable-xmlrpc' ),
87 );
88
89 $disabled = (int) $this->settings->get_option( 'login_security', 'disable_xmlrpc', 0 );
90 if ( $disabled ) {
91 $args['detail'] = __( 'Vigilant XML-RPC block is enabled in settings; brute-force and pingback amplification vectors closed.', 'vigilante' );
92 return Vigilante_SA_Check_Result::pass( $args );
93 }
94
95 $args['detail'] = __( 'XML-RPC is still enabled. Unless Jetpack or a mobile app requires it, disable it under Login.', 'vigilante' );
96 return Vigilante_SA_Check_Result::fail( $args );
97 }
98
99 private function check_login_rate_limit() {
100 $args = array(
101 'id' => 'login_rate_limit',
102 'category' => self::SLUG,
103 'max' => 3,
104 'label' => __( 'Brute-force lockout threshold', 'vigilante' ),
105 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'login', 'field-max-attempts' ),
106 );
107
108 $max = (int) $this->settings->get_option( 'login_security', 'max_attempts', 0 );
109 $args['data'] = array( 'max_attempts' => $max );
110
111 if ( 0 === $max ) {
112 $args['detail'] = __( 'Login lockout is not configured. Any IP can try passwords without limit.', 'vigilante' );
113 return Vigilante_SA_Check_Result::fail( $args );
114 }
115 if ( $max > 10 ) {
116 $args['detail'] = sprintf(
117 /* translators: %d: configured attempts */
118 __( 'Lockout after %d attempts is too permissive. Lower it to 5 or fewer.', 'vigilante' ),
119 $max
120 );
121 return Vigilante_SA_Check_Result::warn( $args );
122 }
123 if ( $max > 5 ) {
124 $args['detail'] = sprintf(
125 /* translators: %d: configured attempts */
126 __( 'Lockout after %d attempts is acceptable but tighter is better.', 'vigilante' ),
127 $max
128 );
129 return Vigilante_SA_Check_Result::warn( $args );
130 }
131
132 $args['detail'] = sprintf(
133 /* translators: %d: configured attempts */
134 __( 'Lockout after %d failed attempts.', 'vigilante' ),
135 $max
136 );
137 return Vigilante_SA_Check_Result::pass( $args );
138 }
139
140 private function check_two_factor_enabled() {
141 $args = array(
142 'id' => 'two_factor_enabled',
143 'category' => self::SLUG,
144 'max' => 3,
145 'label' => __( 'Two-factor authentication status', 'vigilante' ),
146 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'login', 'vigilante-section-login-2fa' ),
147 );
148
149 $two_factor = $this->settings->get_option( 'login_security', 'two_factor', array() );
150 if ( ! empty( $two_factor['enabled'] ) ) {
151 $method = isset( $two_factor['method'] ) ? $two_factor['method'] : 'email';
152 $args['detail'] = sprintf(
153 /* translators: %s: 2FA method */
154 __( '2FA enabled globally using %s.', 'vigilante' ),
155 strtoupper( $method )
156 );
157 return Vigilante_SA_Check_Result::pass( $args );
158 }
159
160 $args['detail'] = __( 'Two-factor authentication is disabled. Enable it at least for administrators.', 'vigilante' );
161 return Vigilante_SA_Check_Result::fail( $args );
162 }
163
164 private function check_wp_admin_direct() {
165 $args = array(
166 'id' => 'wp_admin_direct',
167 'category' => self::SLUG,
168 'max' => 2,
169 'label' => __( '/wp-admin/ public access', 'vigilante' ),
170 'fix_link' => '',
171 );
172
173 $url = trailingslashit( admin_url() );
174 $response = Vigilante_SA_Helpers::get( $url, array( 'redirection' => 0 ) );
175
176 if ( is_wp_error( $response ) ) {
177 $args['detail'] = __( 'Could not probe /wp-admin/.', 'vigilante' );
178 return Vigilante_SA_Check_Result::skip( $args );
179 }
180
181 $code = (int) wp_remote_retrieve_response_code( $response );
182 $location = (string) wp_remote_retrieve_header( $response, 'location' );
183
184 // Anonymous access to /wp-admin/ is considered safe when it either
185 // redirects to a login page (typical) or is blocked/hidden (4xx).
186 // A 200 with an HTML body is the only truly bad outcome.
187 if ( in_array( $code, array( 301, 302, 303, 307, 308 ), true ) && $location ) {
188 $args['detail'] = __( '/wp-admin/ redirected anonymous visits to the login page.', 'vigilante' );
189 $args['data'] = array( 'code' => $code, 'location' => $location );
190 return Vigilante_SA_Check_Result::pass( $args );
191 }
192
193 // 4xx => camouflaged URL, firewall rule, or permission denied. All good.
194 if ( $code >= 400 && $code < 500 ) {
195 $args['detail'] = sprintf(
196 /* translators: %d: HTTP status code */
197 __( '/wp-admin/ returned %d to anonymous visitors.', 'vigilante' ),
198 $code
199 );
200 $args['data'] = array( 'code' => $code );
201 return Vigilante_SA_Check_Result::pass( $args );
202 }
203
204 if ( 200 === $code ) {
205 $args['detail'] = __( '/wp-admin/ returned 200 OK to an anonymous request. Verify nothing sensitive is exposed.', 'vigilante' );
206 $args['data'] = array( 'code' => $code );
207 return Vigilante_SA_Check_Result::fail( $args );
208 }
209
210 // 5xx or other weirdness — server issue, not a hardening problem on our side.
211 $args['detail'] = sprintf(
212 /* translators: %d: HTTP status code */
213 __( '/wp-admin/ responded with %d. This usually indicates a server error rather than a security issue.', 'vigilante' ),
214 $code
215 );
216 $args['data'] = array( 'code' => $code );
217 return Vigilante_SA_Check_Result::warn( $args );
218 }
219
220 private function check_rest_users_me() {
221 $args = array(
222 'id' => 'rest_users_me',
223 'category' => self::SLUG,
224 'max' => 2,
225 'label' => __( 'REST /wp/v2/users/me access', 'vigilante' ),
226 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'rest-api', 'vigilante-section-rest-api-main' ),
227 );
228
229 $url = rest_url( 'wp/v2/users/me' );
230 $response = Vigilante_SA_Helpers::get( $url, array( 'redirection' => 0 ) );
231
232 if ( is_wp_error( $response ) ) {
233 $args['detail'] = __( 'Could not probe /wp/v2/users/me.', 'vigilante' );
234 return Vigilante_SA_Check_Result::skip( $args );
235 }
236
237 $code = (int) wp_remote_retrieve_response_code( $response );
238 if ( 401 === $code ) {
239 $args['detail'] = __( 'The endpoint returns 401 to unauthenticated requests.', 'vigilante' );
240 return Vigilante_SA_Check_Result::pass( $args );
241 }
242 if ( 403 === $code ) {
243 $args['detail'] = __( 'The endpoint returns 403 to unauthenticated requests.', 'vigilante' );
244 return Vigilante_SA_Check_Result::pass( $args );
245 }
246
247 $args['detail'] = sprintf(
248 /* translators: %d: HTTP status code */
249 __( '/wp/v2/users/me responded with %d — should be 401 for anonymous.', 'vigilante' ),
250 $code
251 );
252 return Vigilante_SA_Check_Result::fail( $args );
253 }
254 }
255