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

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

320 lines 14.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 — 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 if ( Vigilante_SA_Helpers::public_url_returns_ok( 'wp-content/debug.log' ) ) {
76 $args['detail'] = __( 'wp-content/debug.log is reachable publicly. Errors and paths are leaking.', 'vigilante' );
77 return Vigilante_SA_Check_Result::fail( $args );
78 }
79
80 $args['detail'] = __( 'wp-content/debug.log exists but is not publicly reachable.', 'vigilante' );
81 return Vigilante_SA_Check_Result::pass( $args );
82 }
83
84 private function check_env_file() {
85 $args = array(
86 'id' => 'env_file',
87 'category' => self::SLUG,
88 'max' => 2,
89 'label' => __( '.env exposure', 'vigilante' ),
90 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'vigilante-section-firewall-server' ),
91 );
92
93 if ( Vigilante_SA_Helpers::public_url_returns_ok( '.env' ) ) {
94 $args['detail'] = __( 'A public /.env file is reachable at the site root. This typically leaks credentials.', 'vigilante' );
95 return Vigilante_SA_Check_Result::fail( $args );
96 }
97
98 $args['detail'] = __( 'No public /.env file is reachable.', 'vigilante' );
99 return Vigilante_SA_Check_Result::pass( $args );
100 }
101
102 private function check_git_config() {
103 $args = array(
104 'id' => 'git_config',
105 'category' => self::SLUG,
106 'max' => 1,
107 'label' => __( '.git/config exposure', 'vigilante' ),
108 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'vigilante-section-firewall-server' ),
109 );
110
111 if ( Vigilante_SA_Helpers::public_url_returns_ok( '.git/config' ) ) {
112 $args['detail'] = __( 'A public /.git/config file is reachable. The repository contents may be cloneable.', 'vigilante' );
113 return Vigilante_SA_Check_Result::fail( $args );
114 }
115
116 $args['detail'] = __( 'No public /.git/config file is reachable.', 'vigilante' );
117 return Vigilante_SA_Check_Result::pass( $args );
118 }
119
120 private function check_wp_config_backups() {
121 $args = array(
122 'id' => 'wp_config_backups',
123 'category' => self::SLUG,
124 'max' => 2,
125 'label' => __( 'wp-config backup files', 'vigilante' ),
126 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'vigilante-section-firewall-server' ),
127 );
128
129 $candidates = array(
130 'wp-config.php.bak',
131 'wp-config.php.old',
132 'wp-config.php.txt',
133 'wp-config.php.save',
134 'wp-config.php~',
135 'wp-config-sample.php.bak',
136 );
137
138 $leaked = array();
139 foreach ( $candidates as $rel ) {
140 if ( Vigilante_SA_Helpers::abspath_file_exists( $rel ) && Vigilante_SA_Helpers::public_url_returns_ok( $rel ) ) {
141 $leaked[] = $rel;
142 }
143 }
144
145 if ( empty( $leaked ) ) {
146 $args['detail'] = __( 'No wp-config backup variants are present or reachable.', 'vigilante' );
147 return Vigilante_SA_Check_Result::pass( $args );
148 }
149
150 $args['data'] = array( 'files' => $leaked );
151 $args['detail'] = sprintf(
152 /* translators: %s: comma-separated files */
153 __( 'Backup copies of wp-config.php are reachable: %s', 'vigilante' ),
154 implode( ', ', $leaked )
155 );
156 return Vigilante_SA_Check_Result::fail( $args );
157 }
158
159 private function check_uploads_listing() {
160 $args = array(
161 'id' => 'uploads_listing',
162 'category' => self::SLUG,
163 'max' => 1,
164 'label' => __( 'Directory listing in /uploads', 'vigilante' ),
165 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'field-disable-directory-browsing' ),
166 );
167
168 $upload_dir = wp_get_upload_dir();
169 if ( empty( $upload_dir['baseurl'] ) ) {
170 $args['detail'] = __( 'Could not determine uploads URL.', 'vigilante' );
171 return Vigilante_SA_Check_Result::skip( $args );
172 }
173
174 $response = Vigilante_SA_Helpers::get( trailingslashit( $upload_dir['baseurl'] ), array( 'redirection' => 0 ) );
175 if ( is_wp_error( $response ) ) {
176 $args['detail'] = __( 'Could not probe the uploads directory URL.', 'vigilante' );
177 return Vigilante_SA_Check_Result::skip( $args );
178 }
179
180 $body = (string) wp_remote_retrieve_body( $response );
181 $code = (int) wp_remote_retrieve_response_code( $response );
182
183 // Indicators that Apache-style directory listing is on.
184 $indicators = array( 'Index of /', '<title>Index of', 'Parent Directory</a>' );
185 $open = false;
186 foreach ( $indicators as $needle ) {
187 if ( 200 === $code && false !== stripos( $body, $needle ) ) {
188 $open = true;
189 break;
190 }
191 }
192
193 if ( $open ) {
194 $args['detail'] = __( 'Directory listing is enabled for the uploads folder. Turn off indexing in .htaccess.', 'vigilante' );
195 return Vigilante_SA_Check_Result::fail( $args );
196 }
197
198 $args['detail'] = __( 'No directory listing markers found in the /uploads/ response.', 'vigilante' );
199 return Vigilante_SA_Check_Result::pass( $args );
200 }
201
202 private function check_phpinfo_adminer() {
203 $args = array(
204 'id' => 'phpinfo_adminer',
205 'category' => self::SLUG,
206 'max' => 1,
207 'label' => __( 'phpinfo / adminer / installer scripts', 'vigilante' ),
208 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'vigilante-section-firewall-server' ),
209 );
210
211 $candidates = array(
212 'phpinfo.php',
213 'info.php',
214 'adminer.php',
215 'installer.php',
216 'install.php', // WP's own install.php is fine; this check catches third-party installers.
217 'test.php',
218 );
219
220 $leaked = array();
221 foreach ( $candidates as $rel ) {
222 if ( 'install.php' === $rel ) {
223 // Skip WP core install.php (always present during setup).
224 continue;
225 }
226 if ( Vigilante_SA_Helpers::abspath_file_exists( $rel ) && Vigilante_SA_Helpers::public_url_returns_ok( $rel ) ) {
227 $leaked[] = $rel;
228 }
229 }
230
231 if ( empty( $leaked ) ) {
232 $args['detail'] = __( 'No phpinfo, adminer or third-party installer scripts are reachable.', 'vigilante' );
233 return Vigilante_SA_Check_Result::pass( $args );
234 }
235
236 $args['data'] = array( 'files' => $leaked );
237 $args['detail'] = sprintf(
238 /* translators: %s: comma-separated files */
239 __( 'Developer/debug scripts reachable: %s. Delete them immediately.', 'vigilante' ),
240 implode( ', ', $leaked )
241 );
242 return Vigilante_SA_Check_Result::fail( $args );
243 }
244
245 private function check_wp_cron_public() {
246 $args = array(
247 'id' => 'wp_cron_public',
248 'category' => self::SLUG,
249 'max' => 1,
250 'label' => __( 'wp-cron.php public access', 'vigilante' ),
251 'fix_link' => Vigilante_SA_Helpers::build_fix_url( 'firewall', 'field-protect-wp-cron' ),
252 );
253
254 // Two separate protections, both off by default and both controlled by Vigilant.
255 // DISABLE_WP_CRON (wp-config constant) stops WordPress from auto-spawning cron on
256 // page views — does NOT block direct HTTP access to /wp-cron.php. The .htaccess
257 // rule (firewall.protect_wp_cron) is what actually blocks external cron-spam
258 // abuse. Both depend on the host having a real server-side cron job calling
259 // wp-cron.php from CLI. Read both Vigilant settings AND the runtime constant —
260 // a savvy user might define DISABLE_WP_CRON manually outside the plugin.
261 $constant_active = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON;
262 $disable_setting = (int) $this->settings->get_option( 'wp_hardening', 'disable_wp_cron', 0 );
263 $htaccess_setting = (int) $this->settings->get_option( 'firewall', 'protect_wp_cron', 0 );
264
265 $url = home_url( '/wp-cron.php?doing_wp_cron=probe' );
266 $response = Vigilante_SA_Helpers::get( $url, array( 'redirection' => 0, 'timeout' => 2 ) );
267 if ( is_wp_error( $response ) ) {
268 $args['detail'] = __( 'Could not reach wp-cron.php from this server. If a firewall is blocking it, that is the desired state.', 'vigilante' );
269 $args['data'] = array(
270 'constant_active' => $constant_active,
271 'disable_setting' => (bool) $disable_setting,
272 'htaccess_setting' => (bool) $htaccess_setting,
273 );
274 return Vigilante_SA_Check_Result::pass( $args );
275 }
276
277 $code = (int) wp_remote_retrieve_response_code( $response );
278 $args['data'] = array(
279 'code' => $code,
280 'constant_active' => $constant_active,
281 'disable_setting' => (bool) $disable_setting,
282 'htaccess_setting' => (bool) $htaccess_setting,
283 );
284
285 // 403/401: blocked at the server level — best case.
286 if ( in_array( $code, array( 401, 403 ), true ) ) {
287 if ( $htaccess_setting && $constant_active ) {
288 $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' );
289 } elseif ( $htaccess_setting ) {
290 $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' );
291 } else {
292 $args['detail'] = __( 'Server returned 403/401 for /wp-cron.php; direct external access is blocked at the server level.', 'vigilante' );
293 }
294 return Vigilante_SA_Check_Result::pass( $args );
295 }
296
297 // 200/204/302: reachable. Differentiate by which protections are active so the
298 // detail message tells the user exactly what's left to do.
299 if ( $constant_active && $htaccess_setting ) {
300 // Settings on but probe still goes through — usually a cache/CDN bypassing
301 // .htaccess, or .htaccess not being loaded by the server (nginx, IIS).
302 $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' );
303 return Vigilante_SA_Check_Result::warn( $args );
304 }
305
306 if ( $constant_active ) {
307 $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' );
308 return Vigilante_SA_Check_Result::warn( $args );
309 }
310
311 if ( $htaccess_setting ) {
312 $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' );
313 return Vigilante_SA_Check_Result::warn( $args );
314 }
315
316 $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' );
317 return Vigilante_SA_Check_Result::warn( $args );
318 }
319 }
320