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

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

459 lines 15.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 — Shared helpers.
4 *
5 * HTTP/SSL probes, grade calculation, admin URL builder, PHP EOL table.
6 *
7 * @package Vigilante
8 * @since 2.1.0
9 */
10
11 // Prevent direct access.
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Static utilities for the Security Analyzer categories.
18 */
19 class Vigilante_SA_Helpers {
20
21 /**
22 * Cached GET of home URL headers+body. Lives in a runtime static so
23 * multiple checks within the same scan don't hit the front-end twice.
24 *
25 * @var array|null
26 */
27 private static $home_probe = null;
28
29 /**
30 * Cached TLS/cert info for home URL.
31 *
32 * @var array|null
33 */
34 private static $tls_info = null;
35
36 /**
37 * PHP End-of-Life table (YYYY-MM-DD). Hardcoded to avoid external API.
38 * Source: https://www.php.net/supported-versions.php
39 * Update with each Vigilante release if a PHP branch enters or exits support.
40 *
41 * @return array<string,string>
42 */
43 public static function php_eol_table() {
44 return array(
45 '7.4' => '2022-11-28',
46 '8.0' => '2023-11-26',
47 '8.1' => '2025-12-31',
48 '8.2' => '2026-12-31',
49 '8.3' => '2027-12-31',
50 '8.4' => '2028-12-31',
51 '8.5' => '2029-12-31',
52 );
53 }
54
55 /**
56 * Return expected major.minor key for the current PHP runtime (e.g. "8.2").
57 *
58 * @return string
59 */
60 public static function current_php_branch() {
61 return PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
62 }
63
64 /**
65 * Compute a 0-100 score and A..E grade aligned with the Dashboard palette.
66 *
67 * @param int $earned Points earned.
68 * @param int $maximum Total available points (should be <=100).
69 * @return array { int score, string grade }
70 */
71 public static function compute_grade( $earned, $maximum ) {
72 $maximum = max( 1, (int) $maximum );
73 $score = (int) round( ( max( 0, (int) $earned ) / $maximum ) * 100 );
74 $score = max( 0, min( 100, $score ) );
75
76 return array(
77 'score' => $score,
78 'grade' => self::grade_for_score( $score ),
79 );
80 }
81
82 /**
83 * The letter a 0-100 score earns
84 *
85 * One scale for every letter the plugin shows: the Security Check, each of
86 * its categories and the Test Headers button. Until 3.0.4 there were two,
87 * so the same letter meant two things on the same screen: the Dashboard
88 * gave its Configuration Score an A from 90 and the Security Check only at
89 * 100, and a B ran from 70 to 99, which put a site with thirteen failing
90 * checks one letter away from a perfect one. The configuration level of
91 * the Dashboard no longer carries a letter: it is a bar
92 * (Vigilante_Admin::render_configuration_card()).
93 *
94 * A is a perfect score and nothing else. The admin script mirrors these
95 * thresholds in VigilanteAnalyzer.qualityTag (assets/js/admin.js).
96 *
97 * @since 3.0.5
98 *
99 * @param int $score Score from 0 to 100.
100 * @return string A, B, C, D or E.
101 */
102 public static function grade_for_score( $score ) {
103 $score = max( 0, min( 100, (int) $score ) );
104
105 if ( 100 === $score ) {
106 return 'A';
107 }
108 if ( $score >= 85 ) {
109 return 'B';
110 }
111 if ( $score >= 70 ) {
112 return 'C';
113 }
114 if ( $score >= 50 ) {
115 return 'D';
116 }
117 return 'E';
118 }
119
120 /**
121 * What a check says when the setting is saved and the module that applies it is off
122 *
123 * A setting inside a module does nothing while the module is switched off:
124 * its class is never loaded. A check that reads the setting alone reports a
125 * protection that is not running.
126 *
127 * @since 3.0.5
128 *
129 * @param Vigilante_Settings $settings Settings.
130 * @param string $module Module key, for example login_security.
131 * @return string
132 */
133 public static function module_off_detail( Vigilante_Settings $settings, $module ) {
134 return sprintf(
135 /* translators: %s: name of a Vigilant module, for example Login Security */
136 __( 'The %s module is switched off, so what is set there is saved but not applied. Switch the module on from the Dashboard.', 'vigilante' ),
137 self::module_label( $settings, $module )
138 );
139 }
140
141 /**
142 * What a check says when the module it reads from is off
143 *
144 * @since 3.0.5
145 *
146 * @param Vigilante_Settings $settings Settings.
147 * @param string $module Module key, for example file_integrity.
148 * @return string
149 */
150 public static function module_idle_detail( Vigilante_Settings $settings, $module ) {
151 return sprintf(
152 /* translators: %s: name of a Vigilant module, for example File Integrity */
153 __( 'The %s module is switched off, so there is nothing recent for this check to read. Switch the module on from the Dashboard.', 'vigilante' ),
154 self::module_label( $settings, $module )
155 );
156 }
157
158 /**
159 * Name of a module as the Dashboard shows it
160 *
161 * @since 3.0.5
162 *
163 * @param Vigilante_Settings $settings Settings.
164 * @param string $module Module key.
165 * @return string
166 */
167 private static function module_label( Vigilante_Settings $settings, $module ) {
168 $labels = $settings->get_module_labels();
169
170 return isset( $labels[ $module ] ) ? (string) $labels[ $module ] : (string) $module;
171 }
172
173 /**
174 * Build a fix link pointing to a specific Vigilante tab + section/field.
175 *
176 * Format: admin.php?page=vigilante&tab=<tab>#<anchor>
177 * Where <anchor> is either vigilante-section-<section> or field-<slug>.
178 *
179 * @param string $tab Tab slug (dashboard, firewall, headers, etc).
180 * @param string $anchor Anchor without leading "#".
181 * @return string
182 */
183 public static function build_fix_url( $tab, $anchor = '' ) {
184 $url = admin_url( 'admin.php?page=vigilante&tab=' . rawurlencode( $tab ) );
185 if ( $anchor ) {
186 $url .= '#' . $anchor;
187 }
188 return $url;
189 }
190
191 /**
192 * Fetch the front-end homepage once per scan and cache the result.
193 * Returns null if the request could not be completed.
194 *
195 * @param bool $force_refresh Bust the in-memory cache.
196 * @return array|null { int code, array headers, string body }
197 */
198 public static function probe_home( $force_refresh = false ) {
199 if ( ! $force_refresh && null !== self::$home_probe ) {
200 return self::$home_probe;
201 }
202
203 $response = wp_remote_get(
204 home_url( '/' ),
205 array(
206 'timeout' => 4,
207 'redirection' => 2,
208 'sslverify' => true,
209 'headers' => array(
210 'User-Agent' => 'Vigilant-Security-Analyzer/1.0',
211 ),
212 )
213 );
214
215 if ( is_wp_error( $response ) ) {
216 self::$home_probe = null;
217 return null;
218 }
219
220 self::$home_probe = array(
221 'code' => (int) wp_remote_retrieve_response_code( $response ),
222 'headers' => self::normalize_headers( wp_remote_retrieve_headers( $response ) ),
223 'body' => (string) wp_remote_retrieve_body( $response ),
224 );
225 return self::$home_probe;
226 }
227
228 /**
229 * Fetch any URL with hardened defaults. Returns WP_Error or response array.
230 *
231 * @param string $url Full URL.
232 * @param array $extra Extra wp_remote_get arguments.
233 * @return array|WP_Error
234 */
235 public static function get( $url, array $extra = array() ) {
236 $args = array_merge(
237 array(
238 'timeout' => 3,
239 'redirection' => 0,
240 'sslverify' => true,
241 'headers' => array(
242 'User-Agent' => 'Vigilant-Security-Analyzer/1.0',
243 ),
244 ),
245 $extra
246 );
247 return wp_remote_get( $url, $args );
248 }
249
250 /**
251 * Normalize Requests_Utility_CaseInsensitiveDictionary (or plain array) to a lowercase-keyed array.
252 *
253 * @param mixed $headers Response headers from wp_remote_retrieve_headers.
254 * @return array<string,string>
255 */
256 public static function normalize_headers( $headers ) {
257 $result = array();
258 if ( is_object( $headers ) && method_exists( $headers, 'getAll' ) ) {
259 foreach ( $headers->getAll() as $key => $value ) {
260 $result[ strtolower( $key ) ] = is_array( $value ) ? implode( ', ', $value ) : (string) $value;
261 }
262 return $result;
263 }
264 if ( is_array( $headers ) ) {
265 foreach ( $headers as $key => $value ) {
266 $result[ strtolower( $key ) ] = is_array( $value ) ? implode( ', ', $value ) : (string) $value;
267 }
268 }
269 return $result;
270 }
271
272 /**
273 * Inspect the SSL certificate of the site via stream_socket_client.
274 * Returns an array with { valid, issuer, valid_from, valid_to, days_left, tls_version } or null on failure.
275 *
276 * @param bool $force_refresh Bust in-memory cache.
277 * @return array|null
278 */
279 public static function probe_tls( $force_refresh = false ) {
280 if ( ! $force_refresh && null !== self::$tls_info ) {
281 return self::$tls_info;
282 }
283
284 if ( ! function_exists( 'stream_socket_client' ) || ! function_exists( 'openssl_x509_parse' ) ) {
285 self::$tls_info = null;
286 return null;
287 }
288
289 $host = wp_parse_url( home_url(), PHP_URL_HOST );
290 $port = (int) wp_parse_url( home_url(), PHP_URL_PORT );
291 if ( ! $host ) {
292 self::$tls_info = null;
293 return null;
294 }
295 if ( ! $port ) {
296 $port = 443;
297 }
298
299 $context = stream_context_create(
300 array(
301 'ssl' => array(
302 'capture_peer_cert' => true,
303 'verify_peer' => false, // We read info; verification is a separate concern.
304 'verify_peer_name' => false,
305 'SNI_enabled' => true,
306 'peer_name' => $host,
307 ),
308 )
309 );
310
311 // phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
312 $client = @stream_socket_client(
313 'ssl://' . $host . ':' . $port,
314 $err_no,
315 $err_str,
316 3,
317 STREAM_CLIENT_CONNECT,
318 $context
319 );
320 // phpcs:enable
321
322 if ( ! $client ) {
323 self::$tls_info = array(
324 'valid' => false,
325 'error' => $err_str ? $err_str : 'connect_failed',
326 );
327 return self::$tls_info;
328 }
329
330 $params = stream_context_get_params( $client );
331 $info = array(
332 'valid' => false,
333 'issuer' => '',
334 'subject' => '',
335 'valid_from' => 0,
336 'valid_to' => 0,
337 'days_left' => null,
338 'tls_version' => '',
339 );
340
341 if ( isset( $params['options']['ssl']['peer_certificate'] ) ) {
342 $parsed = openssl_x509_parse( $params['options']['ssl']['peer_certificate'] );
343 if ( is_array( $parsed ) ) {
344 $info['valid'] = true;
345 $info['issuer'] = isset( $parsed['issuer']['CN'] ) ? $parsed['issuer']['CN'] : '';
346 $info['subject'] = isset( $parsed['subject']['CN'] ) ? $parsed['subject']['CN'] : '';
347 $info['valid_from'] = isset( $parsed['validFrom_time_t'] ) ? (int) $parsed['validFrom_time_t'] : 0;
348 $info['valid_to'] = isset( $parsed['validTo_time_t'] ) ? (int) $parsed['validTo_time_t'] : 0;
349 if ( $info['valid_to'] ) {
350 $info['days_left'] = (int) floor( ( $info['valid_to'] - time() ) / DAY_IN_SECONDS );
351 }
352 }
353 }
354
355 // Detect negotiated TLS version from stream meta.
356 $meta = stream_get_meta_data( $client );
357 if ( isset( $meta['crypto']['protocol'] ) ) {
358 $info['tls_version'] = (string) $meta['crypto']['protocol'];
359 }
360
361 // Close the TLS socket. $client is a network stream resource (not a filesystem
362 // file handle), so WP_Filesystem does not apply here — fclose() is correct.
363 fclose( $client ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
364
365 self::$tls_info = $info;
366 return self::$tls_info;
367 }
368
369 /**
370 * Reset memoized probe caches. Called at the start of each scan.
371 */
372 public static function reset_cache() {
373 self::$home_probe = null;
374 self::$tls_info = null;
375 }
376
377 /**
378 * Human-readable relative time for "last scan" display.
379 *
380 * @param int $timestamp Unix timestamp.
381 * @return string
382 */
383 public static function human_time_diff( $timestamp ) {
384 if ( $timestamp <= 0 ) {
385 return __( 'Never', 'vigilante' );
386 }
387 return sprintf(
388 /* translators: %s: human-readable duration */
389 __( '%s ago', 'vigilante' ),
390 human_time_diff( $timestamp, time() )
391 );
392 }
393
394 /**
395 * Simple filesystem existence test with URL-based fallback. Some hosts hide
396 * files that exist on disk, so file_exists is the authoritative local check
397 * and the URL probe is a defensive secondary signal.
398 *
399 * @param string $relative Path relative to ABSPATH (e.g. "readme.html").
400 * @return bool
401 */
402 public static function abspath_file_exists( $relative ) {
403 $path = trailingslashit( ABSPATH ) . ltrim( $relative, '/' );
404 return file_exists( $path );
405 }
406
407 /**
408 * Check whether a public URL returns HTTP 200. Used to confirm exposure of
409 * sensitive files when file_exists flagged them.
410 *
411 * @param string $relative Path relative to site root.
412 * @return bool True if URL returns 2xx.
413 */
414 public static function public_url_returns_ok( $relative ) {
415 return 'reachable' === self::public_url_state( $relative );
416 }
417
418 /**
419 * Whether a public URL can be fetched, cannot, or could not be asked
420 *
421 * Three answers, because a request that fails is not a file that is
422 * protected: until 3.0.4 a timeout read as "not reachable" and the check
423 * passed without having measured anything.
424 *
425 * @since 3.0.5
426 *
427 * @param string $relative Path relative to site root.
428 * @return string reachable (2xx), blocked (any other answer) or unknown (no answer).
429 */
430 public static function public_url_state( $relative ) {
431 $url = trailingslashit( home_url() ) . ltrim( $relative, '/' );
432 $response = self::get( $url, array( 'redirection' => 0 ) );
433
434 if ( is_wp_error( $response ) ) {
435 return 'unknown';
436 }
437
438 $code = (int) wp_remote_retrieve_response_code( $response );
439
440 return ( $code >= 200 && $code < 300 ) ? 'reachable' : 'blocked';
441 }
442
443 /**
444 * What a check says when the request it depends on got no answer
445 *
446 * @since 3.0.5
447 *
448 * @param string $what What was asked for, for example /.env.
449 * @return string
450 */
451 public static function unreachable_detail( $what ) {
452 return sprintf(
453 /* translators: %s: a path of the site, for example /.env */
454 __( 'The request for %s got no answer from this server, so whether it is reachable from the web could not be checked. Not scored.', 'vigilante' ),
455 $what
456 );
457 }
458 }
459