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-files.php

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

414 lines 17.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Security Analyzer — Sensitive files category (10 pts).
4 *
5 * Checks: debug_log_public (2), env_file (2), git_config (1),
6 * wp_config_backups (2), uploads_listing (1), phpinfo_adminer (1),
7 * wp_cron_public (1).
8 *
9 * @package Vigilante
10 * @since 2.1.0
11 */
12
13 // Prevent direct access.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Sensitive-file exposure checks.
20 */
21 class Vigilante_SA_Category_Files {
22
23 const SLUG = 'files';
24
25 /**
26 * @var Vigilante_Settings
27 */
28 private $settings;
29
30 public function __construct( Vigilante_Settings $settings ) {
31 $this->settings = $settings;
32 }
33
34 /**
35 * Run the category.
36 *
37 * @param string $phase 'fast' | 'slow' | 'all'.
38 * @return Vigilante_SA_Check_Result[]
39 */
40 public function run( $phase = 'all' ) {
41 $results = array();
42
43 // All checks do both a filesystem test and a URL probe — both need phase 'slow'
44 // because the URL probe is what confirms actual public exposure.
45 if ( 'fast' === $phase ) {
46 return $results;
47 }
48
49 $results[] = $this->check_debug_log();
50 $results[] = $this->check_env_file();
51 $results[] = $this->check_git_config();
52 $results[] = $this->check_wp_config_backups();
53 $results[] = $this->check_uploads_listing();
54 $results[] = $this->check_phpinfo_adminer();
55 $results[] = $this->check_wp_cron_public();
56
57 return $results;
58 }
59
60 private function check_debug_log() {
61 $path = WP_CONTENT_DIR . '/debug.log';
62 $args = array(
63 'id' => 'debug_log_public',
64 'category' => self::SLUG,
65 'max' => 2,
66 'label' => __( 'debug.log public access', 'vigilante' ),
67 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'wp-hardening', 'field-wp-debug' ),
68 );
69
70 if ( ! file_exists( $path ) ) {
71 $args['detail'] = __( 'No wp-content/debug.log file present.', 'vigilante' );
72 return Vigilante_SA_Check_Result::pass( $args );
73 }
74
75 $state = Vigilante_SA_Helpers::public_url_state( 'wp-content/debug.log' );
76
77 if ( 'reachable' === $state ) {
78 $args['detail'] = __( 'wp-content/debug.log is reachable publicly. Errors and paths are leaking.', 'vigilante' );
79 return Vigilante_SA_Check_Result::fail( $args );
80 }
81
82 // No answer is not a file that is protected.
83 if ( 'unknown' === $state ) {
84 $args['detail'] = Vigilante_SA_Helpers::unreachable_detail( '/wp-content/debug.log' );
85 return Vigilante_SA_Check_Result::skip( $args );
86 }
87
88 $args['detail'] = __( 'wp-content/debug.log exists but is not publicly reachable.', 'vigilante' );
89 return Vigilante_SA_Check_Result::pass( $args );
90 }
91
92 private function check_env_file() {
93 $args = array(
94 'id' => 'env_file',
95 'category' => self::SLUG,
96 'max' => 2,
97 'label' => __( '.env exposure', 'vigilante' ),
98 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'vigilante-section-firewall-server' ),
99 );
100
101 $state = Vigilante_SA_Helpers::public_url_state( '.env' );
102
103 if ( 'reachable' === $state ) {
104 $args['detail'] = __( 'A public /.env file is reachable at the site root. This typically leaks credentials.', 'vigilante' );
105 return Vigilante_SA_Check_Result::fail( $args );
106 }
107
108 if ( 'unknown' === $state ) {
109 $args['detail'] = Vigilante_SA_Helpers::unreachable_detail( '/.env' );
110 return Vigilante_SA_Check_Result::skip( $args );
111 }
112
113 $args['detail'] = __( 'No public /.env file is reachable.', 'vigilante' );
114 return Vigilante_SA_Check_Result::pass( $args );
115 }
116
117 private function check_git_config() {
118 $args = array(
119 'id' => 'git_config',
120 'category' => self::SLUG,
121 'max' => 1,
122 'label' => __( '.git/config exposure', 'vigilante' ),
123 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'vigilante-section-firewall-server' ),
124 );
125
126 $state = Vigilante_SA_Helpers::public_url_state( '.git/config' );
127
128 if ( 'reachable' === $state ) {
129 $args['detail'] = __( 'A public /.git/config file is reachable. The repository contents may be cloneable.', 'vigilante' );
130 return Vigilante_SA_Check_Result::fail( $args );
131 }
132
133 if ( 'unknown' === $state ) {
134 $args['detail'] = Vigilante_SA_Helpers::unreachable_detail( '/.git/config' );
135 return Vigilante_SA_Check_Result::skip( $args );
136 }
137
138 $args['detail'] = __( 'No public /.git/config file is reachable.', 'vigilante' );
139 return Vigilante_SA_Check_Result::pass( $args );
140 }
141
142 private function check_wp_config_backups() {
143 $args = array(
144 'id' => 'wp_config_backups',
145 'category' => self::SLUG,
146 'max' => 2,
147 'label' => __( 'wp-config backup files', 'vigilante' ),
148 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'vigilante-section-firewall-server' ),
149 );
150
151 $candidates = array(
152 'wp-config.php.bak',
153 'wp-config.php.old',
154 'wp-config.php.txt',
155 'wp-config.php.save',
156 'wp-config.php~',
157 'wp-config-sample.php.bak',
158 );
159
160 list( $leaked, $unknown ) = $this->reachable_among( $candidates );
161
162 if ( empty( $leaked ) && ! empty( $unknown ) ) {
163 $args['data'] = array( 'unknown' => $unknown );
164 $args['detail'] = Vigilante_SA_Helpers::unreachable_detail( '/' . implode( ', /', $unknown ) );
165 return Vigilante_SA_Check_Result::skip( $args );
166 }
167
168 if ( empty( $leaked ) ) {
169 $args['detail'] = __( 'No wp-config backup variants are present or reachable.', 'vigilante' );
170 return Vigilante_SA_Check_Result::pass( $args );
171 }
172
173 $args['data'] = array( 'files' => $leaked );
174 $args['detail'] = sprintf(
175 /* translators: %s: comma-separated files */
176 __( 'Backup copies of wp-config.php are reachable: %s', 'vigilante' ),
177 implode( ', ', $leaked )
178 );
179 return Vigilante_SA_Check_Result::fail( $args );
180 }
181
182 private function check_uploads_listing() {
183 $args = array(
184 'id' => 'uploads_listing',
185 'category' => self::SLUG,
186 'max' => 1,
187 'label' => __( 'Directory listing in /uploads', 'vigilante' ),
188 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'field-disable-directory-browsing' ),
189 );
190
191 $upload_dir = wp_get_upload_dir();
192 if ( empty( $upload_dir['baseurl'] ) ) {
193 $args['detail'] = __( 'Could not determine uploads URL.', 'vigilante' );
194 return Vigilante_SA_Check_Result::skip( $args );
195 }
196
197 $response = Vigilante_SA_Helpers::get( trailingslashit( $upload_dir['baseurl'] ), array( 'redirection' => 0 ) );
198 if ( is_wp_error( $response ) ) {
199 $args['detail'] = __( 'Could not probe the uploads directory URL.', 'vigilante' );
200 return Vigilante_SA_Check_Result::skip( $args );
201 }
202
203 $body = (string) wp_remote_retrieve_body( $response );
204 $code = (int) wp_remote_retrieve_response_code( $response );
205
206 // Indicators that Apache-style directory listing is on.
207 $indicators = array( 'Index of /', '<title>Index of', 'Parent Directory</a>' );
208 $open = false;
209 foreach ( $indicators as $needle ) {
210 if ( 200 === $code && false !== stripos( $body, $needle ) ) {
211 $open = true;
212 break;
213 }
214 }
215
216 if ( $open ) {
217 $args['detail'] = __( 'Directory listing is enabled for the uploads folder. Turn off indexing in .htaccess.', 'vigilante' );
218 return Vigilante_SA_Check_Result::fail( $args );
219 }
220
221 $args['detail'] = __( 'No directory listing markers found in the /uploads/ response.', 'vigilante' );
222 return Vigilante_SA_Check_Result::pass( $args );
223 }
224
225 private function check_phpinfo_adminer() {
226 $args = array(
227 'id' => 'phpinfo_adminer',
228 'category' => self::SLUG,
229 'max' => 1,
230 'label' => __( 'phpinfo / adminer / installer scripts', 'vigilante' ),
231 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'vigilante-section-firewall-server' ),
232 );
233
234 $candidates = array(
235 'phpinfo.php',
236 'info.php',
237 'adminer.php',
238 'installer.php',
239 'install.php', // WP's own install.php is fine; this check catches third-party installers.
240 'test.php',
241 );
242
243 // WP core install.php is always present during setup: not asked for.
244 list( $leaked, $unknown ) = $this->reachable_among( array_diff( $candidates, array( 'install.php' ) ) );
245
246 if ( empty( $leaked ) && ! empty( $unknown ) ) {
247 $args['data'] = array( 'unknown' => $unknown );
248 $args['detail'] = Vigilante_SA_Helpers::unreachable_detail( '/' . implode( ', /', $unknown ) );
249 return Vigilante_SA_Check_Result::skip( $args );
250 }
251
252 if ( empty( $leaked ) ) {
253 $args['detail'] = __( 'No phpinfo, adminer or third-party installer scripts are reachable.', 'vigilante' );
254 return Vigilante_SA_Check_Result::pass( $args );
255 }
256
257 $args['data'] = array( 'files' => $leaked );
258 $args['detail'] = sprintf(
259 /* translators: %s: comma-separated files */
260 __( 'Developer/debug scripts reachable: %s. Delete them immediately.', 'vigilante' ),
261 implode( ', ', $leaked )
262 );
263 return Vigilante_SA_Check_Result::fail( $args );
264 }
265
266 private function check_wp_cron_public() {
267 $args = array(
268 'id' => 'wp_cron_public',
269 'category' => self::SLUG,
270 'max' => 1,
271 'label' => __( 'wp-cron.php public access', 'vigilante' ),
272 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'field-protect-wp-cron' ),
273 );
274
275 // Two separate protections, both off by default and both controlled by Vigilant.
276 // DISABLE_WP_CRON (wp-config constant) stops WordPress from auto-spawning cron on
277 // page views — does NOT block direct HTTP access to /wp-cron.php. The .htaccess
278 // rule (firewall.protect_wp_cron) is what actually blocks external cron-spam
279 // abuse. Both depend on the host having a real server-side cron job calling
280 // wp-cron.php from CLI. Read both Vigilant settings AND the runtime constant —
281 // a savvy user might define DISABLE_WP_CRON manually outside the plugin.
282 $constant_active = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON;
283 $disable_setting = (int) $this->settings->get_option( 'wp_hardening', 'disable_wp_cron', 0 );
284 // Whether the rule is in force is read from .htaccess itself and not
285 // worked out from the settings. The Protection block that carries it is
286 // written and removed from four places that do not agree on when (the
287 // Firewall module alone, or also the two server identity settings of
288 // the headers tab), so the saved setting says what was asked for and
289 // only the file says what is there.
290 $rule_saved = (bool) $this->settings->get_option( 'firewall', 'protect_wp_cron', 0 );
291 $htaccess_setting = (int) $this->wp_cron_rule_is_written();
292
293 $url = home_url( '/wp-cron.php?doing_wp_cron=probe' );
294 $response = Vigilante_SA_Helpers::get( $url, array( 'redirection' => 0, 'timeout' => 2 ) );
295 if ( is_wp_error( $response ) ) {
296 // No answer proves nothing either way: a firewall that drops the
297 // request looks the same from here as a server that was slow.
298 // Until 3.0.4 it passed.
299 $args['detail'] = Vigilante_SA_Helpers::unreachable_detail( '/wp-cron.php' );
300 $args['data'] = array(
301 'constant_active' => $constant_active,
302 'disable_setting' => (bool) $disable_setting,
303 'htaccess_setting' => (bool) $htaccess_setting,
304 );
305 return Vigilante_SA_Check_Result::skip( $args );
306 }
307
308 $code = (int) wp_remote_retrieve_response_code( $response );
309 $args['data'] = array(
310 'code' => $code,
311 'constant_active' => $constant_active,
312 'disable_setting' => (bool) $disable_setting,
313 'htaccess_setting' => (bool) $htaccess_setting,
314 );
315
316 // 403/401: blocked at the server level — best case.
317 if ( in_array( $code, array( 401, 403 ), true ) ) {
318 if ( $htaccess_setting && $constant_active ) {
319 $args['detail'] = __( 'Vigilant is blocking external HTTP access to wp-cron.php via .htaccess, and DISABLE_WP_CRON is set in wp-config. Make sure your host has a real server-side cron job calling wp-cron.php from CLI.', 'vigilante' );
320 } elseif ( $htaccess_setting ) {
321 $args['detail'] = __( 'Vigilant\'s .htaccess rule blocks /wp-cron.php at the server level. Consider also enabling DISABLE_WP_CRON in WP Hardening so WordPress stops trying to auto-spawn cron on page views.', 'vigilante' );
322 } else {
323 $args['detail'] = __( 'Server returned 403/401 for /wp-cron.php; direct external access is blocked at the server level.', 'vigilante' );
324 }
325 return Vigilante_SA_Check_Result::pass( $args );
326 }
327
328 // 200/204/302: reachable. Differentiate by which protections are active so the
329 // detail message tells the user exactly what's left to do.
330 if ( $constant_active && $htaccess_setting ) {
331 // Settings on but probe still goes through — usually a cache/CDN bypassing
332 // .htaccess, or .htaccess not being loaded by the server (nginx, IIS).
333 $args['detail'] = __( 'Both DISABLE_WP_CRON and Vigilant\'s .htaccess block are active, but /wp-cron.php is still reachable. Either a cache/CDN is serving an old response, or your server doesn\'t honor .htaccess (nginx/IIS) — check the server config or purge the cache.', 'vigilante' );
334 return Vigilante_SA_Check_Result::warn( $args );
335 }
336
337 if ( $constant_active ) {
338 $args['detail'] = __( 'DISABLE_WP_CRON is set in wp-config (good — your host runs real cron), but /wp-cron.php is still reachable publicly. The constant only stops WordPress from auto-spawning cron on page views; it does not block direct HTTP access. To fully harden, enable "Protect wp-cron.php" in Firewall &rarr; File Protection.', 'vigilante' );
339 return Vigilante_SA_Check_Result::warn( $args );
340 }
341
342 if ( $htaccess_setting ) {
343 $args['detail'] = __( 'Vigilant is configured to block /wp-cron.php at the server level, but the URL is still reachable. Check that .htaccess is being honored by your server (nginx/IIS users need an equivalent rule), then re-scan.', 'vigilante' );
344 return Vigilante_SA_Check_Result::warn( $args );
345 }
346
347 if ( $rule_saved ) {
348 $args['detail'] = __( '"Protect wp-cron.php" is saved in Vigilant, but its rule is not in .htaccess, so /wp-cron.php is still reachable. Open the Firewall tab and save it to write the rule again.', 'vigilante' );
349 return Vigilante_SA_Check_Result::warn( $args );
350 }
351
352 $args['detail'] = __( '/wp-cron.php is publicly reachable. WordPress is using the default page-view trigger, which is functional but lets attackers spam the URL to exhaust resources. If your host supports real cron, enable both "Disable WP Cron" (WP Hardening) and "Protect wp-cron.php" (Firewall) and add a server-side cron job calling wp-cron.php from CLI.', 'vigilante' );
353 return Vigilante_SA_Check_Result::warn( $args );
354 }
355
356 /**
357 * Whether the rule that blocks wp-cron.php is inside Vigilant's block in .htaccess
358 *
359 * @since 3.0.5
360 *
361 * @return bool
362 */
363 private function wp_cron_rule_is_written() {
364 if ( ! class_exists( 'Vigilante_Htaccess_Manager' ) ) {
365 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
366 }
367
368 $content = Vigilante_Htaccess_Manager::get_instance()->get_content();
369
370 if ( ! is_string( $content ) ) {
371 return false;
372 }
373
374 $start = strpos( $content, '# BEGIN Vigilante Protection' );
375 $end = strpos( $content, '# END Vigilante Protection' );
376
377 if ( false === $start || false === $end || $end < $start ) {
378 return false;
379 }
380
381 return false !== strpos( substr( $content, $start, $end - $start ), '<Files "wp-cron.php">' );
382 }
383
384 /**
385 * Which of these files of the site root are on disk and can be fetched
386 *
387 * @since 3.0.5
388 *
389 * @param string[] $candidates Paths relative to ABSPATH.
390 * @return array Two lists: the ones a request reached, and the ones that
391 * are on disk but whose request got no answer.
392 */
393 private function reachable_among( array $candidates ) {
394 $leaked = array();
395 $unknown = array();
396
397 foreach ( $candidates as $rel ) {
398 if ( ! Vigilante_SA_Helpers::abspath_file_exists( $rel ) ) {
399 continue;
400 }
401
402 $state = Vigilante_SA_Helpers::public_url_state( $rel );
403
404 if ( 'reachable' === $state ) {
405 $leaked[] = $rel;
406 } elseif ( 'unknown' === $state ) {
407 $unknown[] = $rel;
408 }
409 }
410
411 return array( $leaked, $unknown );
412 }
413 }
414