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

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

474 lines 20.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Security Analyzer — WP information exposure category (18 pts).
4 *
5 * Checks: meta_generator (2), rest_users_enum (3), author_scanning (2),
6 * wp_version_public (1), readme_exists (1), license_exists (1),
7 * rsd_link (1), wlw_manifest (1), shortlink (1), active_theme_info (info, 5 pts
8 * reserved for bonus "theme up-to-date" when applicable).
9 *
10 * @package Vigilante
11 * @since 2.1.0
12 */
13
14 // Prevent direct access.
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
20 * WordPress exposure checks.
21 */
22 class Vigilante_SA_Category_WP_Exposure {
23
24 const SLUG = 'wp_exposure';
25
26 /**
27 * @var Vigilante_Settings
28 */
29 private $settings;
30
31 public function __construct( Vigilante_Settings $settings ) {
32 $this->settings = $settings;
33 }
34
35 /**
36 * Run the category.
37 *
38 * @param string $phase 'fast' | 'slow' | 'all'.
39 * @return Vigilante_SA_Check_Result[]
40 */
41 public function run( $phase = 'all' ) {
42 $results = array();
43
44 if ( 'fast' === $phase || 'all' === $phase ) {
45 $results[] = $this->check_readme_exists();
46 $results[] = $this->check_license_exists();
47 $results[] = $this->check_rsd_link();
48 $results[] = $this->check_wlw_manifest();
49 $results[] = $this->check_shortlink();
50 $results[] = $this->check_active_theme_info();
51 }
52
53 if ( 'slow' === $phase || 'all' === $phase ) {
54 $results[] = $this->check_meta_generator();
55 $results[] = $this->check_rest_users_enum();
56 $results[] = $this->check_author_scanning();
57 $results[] = $this->check_wp_version_public();
58 }
59
60 return $results;
61 }
62
63 private function check_meta_generator() {
64 $args = array(
65 'id' => 'meta_generator',
66 'category' => self::SLUG,
67 'max' => 2,
68 'label' => __( '<meta name="generator"> in HTML', 'vigilante' ),
69 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'wp-hardening', 'vigilante-section-hardening-headers' ),
70 );
71
72 $remove_generator = (int) $this->settings->get_option( 'wp_hardening', 'remove_wp_generator', 0 );
73 $probe = Vigilante_SA_Helpers::probe_home();
74
75 if ( null === $probe ) {
76 $args['detail'] = __( 'Could not fetch the homepage to inspect HTML meta tags.', 'vigilante' );
77 return Vigilante_SA_Check_Result::skip( $args );
78 }
79
80 $found = (bool) preg_match( '#<meta\s+name=["\']generator["\']#i', $probe['body'] );
81 if ( ! $found ) {
82 $args['detail'] = __( 'No <meta name="generator"> tag found in the homepage HTML.', 'vigilante' );
83 return Vigilante_SA_Check_Result::pass( $args );
84 }
85
86 if ( $remove_generator ) {
87 $args['detail'] = __( 'The "remove generator" setting is on, but a generator meta tag is still rendered — likely from a plugin or theme.', 'vigilante' );
88 return Vigilante_SA_Check_Result::warn( $args );
89 }
90
91 $args['detail'] = __( 'The homepage exposes the WordPress version through a <meta name="generator"> tag.', 'vigilante' );
92 return Vigilante_SA_Check_Result::fail( $args );
93 }
94
95 private function check_rest_users_enum() {
96 $args = array(
97 'id' => 'rest_users_enum',
98 'category' => self::SLUG,
99 'max' => 3,
100 'label' => __( 'REST /wp/v2/users public access', 'vigilante' ),
101 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'rest-api', 'field-block-user-enumeration' ),
102 );
103
104 // The sub-toggle is only effective when the REST hardening module itself is enabled.
105 $module_on = (int) $this->settings->get_option( 'rest_api_security', 'enabled', 0 );
106 $sub_on = (int) $this->settings->get_option( 'rest_api_security', 'block_user_enumeration', 0 );
107 $setting_on = $module_on && $sub_on;
108 $args['data'] = array(
109 'rest_module_enabled' => (bool) $module_on,
110 'block_sub_toggle' => (bool) $sub_on,
111 );
112
113 $url = rest_url( 'wp/v2/users' );
114 $response = Vigilante_SA_Helpers::get( $url, array( 'redirection' => 0 ) );
115
116 if ( is_wp_error( $response ) ) {
117 $args['detail'] = __( 'Could not probe the REST users endpoint.', 'vigilante' );
118 return Vigilante_SA_Check_Result::skip( $args );
119 }
120
121 $code = (int) wp_remote_retrieve_response_code( $response );
122 $body = (string) wp_remote_retrieve_body( $response );
123 $args['data']['code'] = $code;
124
125 // 401/403 => protected at the auth layer (e.g. Vigilante REST mode = authenticated_only).
126 if ( in_array( $code, array( 401, 403 ), true ) ) {
127 $args['detail'] = __( 'REST /wp/v2/users requires authentication.', 'vigilante' );
128 return Vigilante_SA_Check_Result::pass( $args );
129 }
130
131 // 404 with rest_no_route => Vigilante's rest_endpoints filter removed the route. Correct protection.
132 if ( 404 === $code ) {
133 if ( false !== strpos( $body, 'rest_no_route' ) || false !== strpos( $body, '"code":"rest_no_route"' ) ) {
134 $args['detail'] = __( 'REST /wp/v2/users route is unregistered for anonymous clients.', 'vigilante' );
135 return Vigilante_SA_Check_Result::pass( $args );
136 }
137 $args['detail'] = __( 'REST /wp/v2/users returned 404 to the anonymous probe (likely a firewall rule).', 'vigilante' );
138 return Vigilante_SA_Check_Result::pass( $args );
139 }
140
141 if ( 200 === $code ) {
142 $decoded = json_decode( $body, true );
143 if ( is_array( $decoded ) && empty( $decoded ) ) {
144 $args['detail'] = __( 'REST /wp/v2/users returned an empty list to the unauthenticated probe.', 'vigilante' );
145 return Vigilante_SA_Check_Result::pass( $args );
146 }
147 if ( is_array( $decoded ) && ! empty( $decoded ) ) {
148 $args['data']['users_leaked'] = count( $decoded );
149
150 // Most common trap: sub-toggle is on but the REST hardening module master is off.
151 if ( $sub_on && ! $module_on ) {
152 $args['detail'] = __( 'The "block user enumeration" option is on, but the REST API hardening module itself is disabled — so the filter never runs. Enable the REST API module to activate the block.', 'vigilante' );
153 return Vigilante_SA_Check_Result::fail( $args );
154 }
155
156 // Both flags on + still returning data → cache/CDN/priority override.
157 if ( $setting_on ) {
158 $args['detail'] = sprintf(
159 /* translators: %d: number of users returned by the probe */
160 __( 'Block is fully enabled, but the probe still sees %d user records. A page cache, CDN, or higher-priority plugin filter is overriding the block — flush caches and re-scan.', 'vigilante' ),
161 count( $decoded )
162 );
163 return Vigilante_SA_Check_Result::warn( $args );
164 }
165
166 // Nothing on at all → real exposure.
167 $args['detail'] = sprintf(
168 /* translators: %d: number of users leaked */
169 __( 'The REST users endpoint exposes %d user records publicly.', 'vigilante' ),
170 count( $decoded )
171 );
172 return Vigilante_SA_Check_Result::fail( $args );
173 }
174 }
175
176 // Anything else (5xx, odd proxies): trust the Vigilante setting as authoritative
177 // only when BOTH flags are on (module + sub-toggle).
178 if ( $setting_on ) {
179 $args['detail'] = sprintf(
180 /* translators: %d: HTTP status code */
181 __( 'Probe returned status %d; Vigilant user-enumeration block is enabled in settings.', 'vigilante' ),
182 $code
183 );
184 return Vigilante_SA_Check_Result::pass( $args );
185 }
186
187 $args['detail'] = sprintf(
188 /* translators: %d: HTTP status code */
189 __( 'The REST users endpoint responded with status %d — manual review recommended.', 'vigilante' ),
190 $code
191 );
192 return Vigilante_SA_Check_Result::warn( $args );
193 }
194
195 private function check_author_scanning() {
196 $args = array(
197 'id' => 'author_scanning',
198 'category' => self::SLUG,
199 'max' => 2,
200 'label' => __( '?author=N enumeration', 'vigilante' ),
201 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'users', 'field-block-author-scanning' ),
202 );
203
204 // The sub-toggle is only effective when the User Security module is enabled.
205 // If the master module is off, the class is never instantiated and the hook is
206 // never registered — the sub-toggle alone does nothing. Detect both so we can
207 // tell the user exactly which switch is missing.
208 $module_on = (int) $this->settings->is_module_enabled( 'user_security' );
209 $sub_on = (int) $this->settings->get_option( 'user_security', 'block_author_scanning', 0 );
210 $setting_on = $module_on && $sub_on;
211
212 $url = add_query_arg( 'author', 1, home_url( '/' ) );
213 $response = Vigilante_SA_Helpers::get( $url, array( 'redirection' => 0 ) );
214
215 if ( is_wp_error( $response ) ) {
216 $args['detail'] = __( 'Could not probe ?author=1.', 'vigilante' );
217 return Vigilante_SA_Check_Result::skip( $args );
218 }
219
220 $code = (int) wp_remote_retrieve_response_code( $response );
221 $location = (string) wp_remote_retrieve_header( $response, 'location' );
222 $args['data'] = array(
223 'code' => $code,
224 'location' => $location,
225 'module_enabled' => (bool) $module_on,
226 'block_sub_toggle' => (bool) $sub_on,
227 );
228
229 // Stock WP redirects /?author=1 to /author/USERNAME/ — that's the leak.
230 if ( $code >= 300 && $code < 400 && $location && preg_match( '#/author/([^/?]+)#i', $location, $m ) ) {
231 $args['data']['leaked_login'] = $m[1];
232
233 // Common configuration trap: sub-toggle on but master module off.
234 if ( $sub_on && ! $module_on ) {
235 $args['detail'] = sprintf(
236 /* translators: %s: leaked username */
237 __( 'Author scanning still leaks the username "%s". The "Block author scanning" option is on, but the User Security module itself is disabled — so the protection never runs. Enable the module from the Dashboard modules grid.', 'vigilante' ),
238 $m[1]
239 );
240 return Vigilante_SA_Check_Result::fail( $args );
241 }
242
243 // Both flags on but the probe still leaks — usually a caching/CDN layer
244 // serving a stale anonymous response, or a server cache bypassing PHP.
245 if ( $setting_on ) {
246 $args['detail'] = sprintf(
247 /* translators: %s: leaked username */
248 __( 'Author scanning still leaks the username "%s" even though the protection is enabled. A cache or CDN may be serving an old response — purge your page cache and re-scan.', 'vigilante' ),
249 $m[1]
250 );
251 return Vigilante_SA_Check_Result::warn( $args );
252 }
253
254 // Both flags off: clean FAIL.
255 $args['detail'] = sprintf(
256 /* translators: %s: leaked username */
257 __( 'Author scanning leaks the username "%s" via redirect to /author/NAME/.', 'vigilante' ),
258 $m[1]
259 );
260 return Vigilante_SA_Check_Result::fail( $args );
261 }
262
263 // Any 3xx redirect that does NOT point to /author/NAME/ is a valid protection
264 // (Vigilant's block_author_scan redirects to home_url() with 301).
265 if ( $code >= 300 && $code < 400 ) {
266 $args['detail'] = __( '?author=1 redirected to a URL that does not contain /author/<username>/.', 'vigilante' );
267 return Vigilante_SA_Check_Result::pass( $args );
268 }
269
270 if ( 200 === $code || 404 === $code || 403 === $code ) {
271 $args['detail'] = __( '?author=1 response did not contain an author username.', 'vigilante' );
272 return Vigilante_SA_Check_Result::pass( $args );
273 }
274
275 // Anything else: trust the setting if it's on.
276 if ( $setting_on ) {
277 $args['detail'] = sprintf(
278 /* translators: %d: HTTP status code */
279 __( '?author=1 returned %d; Vigilant author-scanning block is enabled in settings.', 'vigilante' ),
280 $code
281 );
282 return Vigilante_SA_Check_Result::pass( $args );
283 }
284
285 $args['detail'] = sprintf(
286 /* translators: %d: HTTP status code */
287 __( '?author=1 returned %d. Manual review recommended.', 'vigilante' ),
288 $code
289 );
290 return Vigilante_SA_Check_Result::warn( $args );
291 }
292
293 private function check_wp_version_public() {
294 $args = array(
295 'id' => 'wp_version_public',
296 'category' => self::SLUG,
297 'max' => 1,
298 'label' => __( 'WordPress version in assets', 'vigilante' ),
299 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'wp-hardening', 'field-remove-wp-version-assets' ),
300 );
301
302 $probe = Vigilante_SA_Helpers::probe_home();
303 if ( null === $probe ) {
304 $args['detail'] = __( 'Could not read homepage HTML.', 'vigilante' );
305 return Vigilante_SA_Check_Result::skip( $args );
306 }
307
308 $version = get_bloginfo( 'version' );
309 $leaked = false;
310 if ( $version && strpos( $probe['body'], '?ver=' . $version ) !== false ) {
311 $leaked = true;
312 }
313 if ( $version && preg_match( '#content=["\']WordPress ' . preg_quote( $version, '#' ) . '["\']#i', $probe['body'] ) ) {
314 $leaked = true;
315 }
316
317 if ( $leaked ) {
318 $args['detail'] = sprintf(
319 /* translators: %s: WordPress version */
320 __( 'The exact WordPress version (%s) is present in homepage asset URLs.', 'vigilante' ),
321 $version
322 );
323 return Vigilante_SA_Check_Result::fail( $args );
324 }
325
326 $args['detail'] = __( 'No WordPress version markers found in homepage asset URLs.', 'vigilante' );
327 return Vigilante_SA_Check_Result::pass( $args );
328 }
329
330 private function check_readme_exists() {
331 $args = array(
332 'id' => 'readme_exists',
333 'category' => self::SLUG,
334 'max' => 1,
335 'label' => __( 'readme.html presence', 'vigilante' ),
336 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'tools', 'vigilante-section-tools-cleanup' ),
337 );
338
339 if ( ! Vigilante_SA_Helpers::abspath_file_exists( 'readme.html' ) ) {
340 $args['detail'] = __( 'readme.html is not present at the WordPress root.', 'vigilante' );
341 return Vigilante_SA_Check_Result::pass( $args );
342 }
343
344 $args['detail'] = __( 'readme.html is present at the site root and leaks the WordPress version to anyone who visits it.', 'vigilante' );
345 return Vigilante_SA_Check_Result::fail( $args );
346 }
347
348 private function check_license_exists() {
349 $args = array(
350 'id' => 'license_exists',
351 'category' => self::SLUG,
352 'max' => 1,
353 'label' => __( 'license.txt presence', 'vigilante' ),
354 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'tools', 'vigilante-section-tools-cleanup' ),
355 );
356
357 if ( ! Vigilante_SA_Helpers::abspath_file_exists( 'license.txt' ) ) {
358 $args['detail'] = __( 'license.txt is not present at the WordPress root.', 'vigilante' );
359 return Vigilante_SA_Check_Result::pass( $args );
360 }
361
362 $args['detail'] = __( 'license.txt is reachable publicly at the site root.', 'vigilante' );
363 return Vigilante_SA_Check_Result::fail( $args );
364 }
365
366 private function check_rsd_link() {
367 return $this->boolean_setting_check(
368 'rsd_link',
369 'wp_hardening',
370 'remove_rsd_link',
371 __( 'RSD link in HTML', 'vigilante' ),
372 __( 'RSD link removal setting is enabled.', 'vigilante' ),
373 __( 'The RSD link is still rendered in the HTML head. Disable it under WP Hardening.', 'vigilante' ),
374 1,
375 Vigilante_SA_Helpers::build_fix_url( 'wp-hardening', 'vigilante-section-hardening-headers' )
376 );
377 }
378
379 private function check_wlw_manifest() {
380 return $this->boolean_setting_check(
381 'wlw_manifest',
382 'wp_hardening',
383 'remove_wlw_manifest',
384 __( 'Windows Live Writer manifest in HTML', 'vigilante' ),
385 __( 'WLW manifest removal setting is enabled.', 'vigilante' ),
386 __( 'The WLW manifest link is still rendered. Disable it under WP Hardening.', 'vigilante' ),
387 1,
388 Vigilante_SA_Helpers::build_fix_url( 'wp-hardening', 'vigilante-section-hardening-headers' )
389 );
390 }
391
392 private function check_shortlink() {
393 return $this->boolean_setting_check(
394 'shortlink',
395 'wp_hardening',
396 'remove_shortlink',
397 __( 'Shortlink header', 'vigilante' ),
398 __( 'Shortlink header removal setting is enabled.', 'vigilante' ),
399 __( 'The Shortlink header is still rendered. Disable it under WP Hardening.', 'vigilante' ),
400 1,
401 Vigilante_SA_Helpers::build_fix_url( 'wp-hardening', 'vigilante-section-hardening-headers' )
402 );
403 }
404
405 private function check_active_theme_info() {
406 $theme = wp_get_theme();
407 $args = array(
408 'id' => 'active_theme_info',
409 'category' => self::SLUG,
410 'max' => 5,
411 'label' => __( 'Active theme', 'vigilante' ),
412 'fix_link' => '',
413 );
414
415 if ( ! $theme || ! $theme->exists() ) {
416 $args['detail'] = __( 'Could not read active theme metadata.', 'vigilante' );
417 return Vigilante_SA_Check_Result::skip( $args );
418 }
419
420 $name = $theme->get( 'Name' );
421 $version = $theme->get( 'Version' );
422 $author = $theme->get( 'Author' );
423
424 $args['data'] = array(
425 'name' => $name,
426 'version' => $version,
427 'author' => $author,
428 );
429 $args['detail'] = sprintf(
430 /* translators: 1: theme name, 2: version, 3: author */
431 __( '%1$s %2$s by %3$s.', 'vigilante' ),
432 $name,
433 $version,
434 wp_strip_all_tags( (string) $author )
435 );
436
437 // Give full info points if no theme update is pending; otherwise warn.
438 if ( ! function_exists( 'get_theme_updates' ) ) {
439 require_once ABSPATH . 'wp-admin/includes/update.php';
440 }
441 $updates = function_exists( 'get_theme_updates' ) ? get_theme_updates() : array();
442 $slug = $theme->get_stylesheet();
443
444 if ( isset( $updates[ $slug ] ) ) {
445 $args['detail'] .= ' ' . __( 'An update is available.', 'vigilante' );
446 return Vigilante_SA_Check_Result::warn( $args );
447 }
448
449 return Vigilante_SA_Check_Result::pass( $args );
450 }
451
452 /**
453 * Shared helper for the three "remove X from head" boolean checks.
454 */
455 private function boolean_setting_check( $id, $section, $key, $label, $pass_detail, $fail_detail, $max, $fix_link ) {
456 $args = array(
457 'id' => $id,
458 'category' => self::SLUG,
459 'max' => $max,
460 'label' => $label,
461 'fix_link' => $fix_link,
462 );
463
464 $enabled = (int) $this->settings->get_option( $section, $key, 0 );
465 if ( $enabled ) {
466 $args['detail'] = $pass_detail;
467 return Vigilante_SA_Check_Result::pass( $args );
468 }
469
470 $args['detail'] = $fail_detail;
471 return Vigilante_SA_Check_Result::fail( $args );
472 }
473 }
474