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

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

345 lines 11.3 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 );
52 }
53
54 /**
55 * Return expected major.minor key for the current PHP runtime (e.g. "8.2").
56 *
57 * @return string
58 */
59 public static function current_php_branch() {
60 return PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
61 }
62
63 /**
64 * Compute a 0-100 score and A..E grade aligned with the Dashboard palette.
65 *
66 * @param int $earned Points earned.
67 * @param int $maximum Total available points (should be <=100).
68 * @return array { int score, string grade }
69 */
70 public static function compute_grade( $earned, $maximum ) {
71 $maximum = max( 1, (int) $maximum );
72 $score = (int) round( ( max( 0, (int) $earned ) / $maximum ) * 100 );
73 $score = max( 0, min( 100, $score ) );
74
75 // A is reserved for a perfect score — a single missing point drops to B.
76 if ( 100 === $score ) {
77 $grade = 'A';
78 } elseif ( $score >= 70 ) {
79 $grade = 'B';
80 } elseif ( $score >= 50 ) {
81 $grade = 'C';
82 } elseif ( $score >= 30 ) {
83 $grade = 'D';
84 } else {
85 $grade = 'E';
86 }
87
88 return array(
89 'score' => $score,
90 'grade' => $grade,
91 );
92 }
93
94 /**
95 * Build a fix link pointing to a specific Vigilante tab + section/field.
96 *
97 * Format: admin.php?page=vigilante&tab=<tab>#<anchor>
98 * Where <anchor> is either vigilante-section-<section> or field-<slug>.
99 *
100 * @param string $tab Tab slug (dashboard, firewall, headers, etc).
101 * @param string $anchor Anchor without leading "#".
102 * @return string
103 */
104 public static function build_fix_url( $tab, $anchor = '' ) {
105 $url = admin_url( 'admin.php?page=vigilante&tab=' . rawurlencode( $tab ) );
106 if ( $anchor ) {
107 $url .= '#' . $anchor;
108 }
109 return $url;
110 }
111
112 /**
113 * Fetch the front-end homepage once per scan and cache the result.
114 * Returns null if the request could not be completed.
115 *
116 * @param bool $force_refresh Bust the in-memory cache.
117 * @return array|null { int code, array headers, string body }
118 */
119 public static function probe_home( $force_refresh = false ) {
120 if ( ! $force_refresh && null !== self::$home_probe ) {
121 return self::$home_probe;
122 }
123
124 $response = wp_remote_get(
125 home_url( '/' ),
126 array(
127 'timeout' => 4,
128 'redirection' => 2,
129 'sslverify' => true,
130 'headers' => array(
131 'User-Agent' => 'Vigilant-Security-Analyzer/1.0',
132 ),
133 )
134 );
135
136 if ( is_wp_error( $response ) ) {
137 self::$home_probe = null;
138 return null;
139 }
140
141 self::$home_probe = array(
142 'code' => (int) wp_remote_retrieve_response_code( $response ),
143 'headers' => self::normalize_headers( wp_remote_retrieve_headers( $response ) ),
144 'body' => (string) wp_remote_retrieve_body( $response ),
145 );
146 return self::$home_probe;
147 }
148
149 /**
150 * Fetch any URL with hardened defaults. Returns WP_Error or response array.
151 *
152 * @param string $url Full URL.
153 * @param array $extra Extra wp_remote_get arguments.
154 * @return array|WP_Error
155 */
156 public static function get( $url, array $extra = array() ) {
157 $args = array_merge(
158 array(
159 'timeout' => 3,
160 'redirection' => 0,
161 'sslverify' => true,
162 'headers' => array(
163 'User-Agent' => 'Vigilant-Security-Analyzer/1.0',
164 ),
165 ),
166 $extra
167 );
168 return wp_remote_get( $url, $args );
169 }
170
171 /**
172 * Normalize Requests_Utility_CaseInsensitiveDictionary (or plain array) to a lowercase-keyed array.
173 *
174 * @param mixed $headers Response headers from wp_remote_retrieve_headers.
175 * @return array<string,string>
176 */
177 public static function normalize_headers( $headers ) {
178 $result = array();
179 if ( is_object( $headers ) && method_exists( $headers, 'getAll' ) ) {
180 foreach ( $headers->getAll() as $key => $value ) {
181 $result[ strtolower( $key ) ] = is_array( $value ) ? implode( ', ', $value ) : (string) $value;
182 }
183 return $result;
184 }
185 if ( is_array( $headers ) ) {
186 foreach ( $headers as $key => $value ) {
187 $result[ strtolower( $key ) ] = is_array( $value ) ? implode( ', ', $value ) : (string) $value;
188 }
189 }
190 return $result;
191 }
192
193 /**
194 * Inspect the SSL certificate of the site via stream_socket_client.
195 * Returns an array with { valid, issuer, valid_from, valid_to, days_left, tls_version } or null on failure.
196 *
197 * @param bool $force_refresh Bust in-memory cache.
198 * @return array|null
199 */
200 public static function probe_tls( $force_refresh = false ) {
201 if ( ! $force_refresh && null !== self::$tls_info ) {
202 return self::$tls_info;
203 }
204
205 if ( ! function_exists( 'stream_socket_client' ) || ! function_exists( 'openssl_x509_parse' ) ) {
206 self::$tls_info = null;
207 return null;
208 }
209
210 $host = wp_parse_url( home_url(), PHP_URL_HOST );
211 $port = (int) wp_parse_url( home_url(), PHP_URL_PORT );
212 if ( ! $host ) {
213 self::$tls_info = null;
214 return null;
215 }
216 if ( ! $port ) {
217 $port = 443;
218 }
219
220 $context = stream_context_create(
221 array(
222 'ssl' => array(
223 'capture_peer_cert' => true,
224 'verify_peer' => false, // We read info; verification is a separate concern.
225 'verify_peer_name' => false,
226 'SNI_enabled' => true,
227 'peer_name' => $host,
228 ),
229 )
230 );
231
232 // phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
233 $client = @stream_socket_client(
234 'ssl://' . $host . ':' . $port,
235 $err_no,
236 $err_str,
237 3,
238 STREAM_CLIENT_CONNECT,
239 $context
240 );
241 // phpcs:enable
242
243 if ( ! $client ) {
244 self::$tls_info = array(
245 'valid' => false,
246 'error' => $err_str ? $err_str : 'connect_failed',
247 );
248 return self::$tls_info;
249 }
250
251 $params = stream_context_get_params( $client );
252 $info = array(
253 'valid' => false,
254 'issuer' => '',
255 'subject' => '',
256 'valid_from' => 0,
257 'valid_to' => 0,
258 'days_left' => null,
259 'tls_version' => '',
260 );
261
262 if ( isset( $params['options']['ssl']['peer_certificate'] ) ) {
263 $parsed = openssl_x509_parse( $params['options']['ssl']['peer_certificate'] );
264 if ( is_array( $parsed ) ) {
265 $info['valid'] = true;
266 $info['issuer'] = isset( $parsed['issuer']['CN'] ) ? $parsed['issuer']['CN'] : '';
267 $info['subject'] = isset( $parsed['subject']['CN'] ) ? $parsed['subject']['CN'] : '';
268 $info['valid_from'] = isset( $parsed['validFrom_time_t'] ) ? (int) $parsed['validFrom_time_t'] : 0;
269 $info['valid_to'] = isset( $parsed['validTo_time_t'] ) ? (int) $parsed['validTo_time_t'] : 0;
270 if ( $info['valid_to'] ) {
271 $info['days_left'] = (int) floor( ( $info['valid_to'] - time() ) / DAY_IN_SECONDS );
272 }
273 }
274 }
275
276 // Detect negotiated TLS version from stream meta.
277 $meta = stream_get_meta_data( $client );
278 if ( isset( $meta['crypto']['protocol'] ) ) {
279 $info['tls_version'] = (string) $meta['crypto']['protocol'];
280 }
281
282 // Close the TLS socket. $client is a network stream resource (not a filesystem
283 // file handle), so WP_Filesystem does not apply here — fclose() is correct.
284 fclose( $client ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
285
286 self::$tls_info = $info;
287 return self::$tls_info;
288 }
289
290 /**
291 * Reset memoized probe caches. Called at the start of each scan.
292 */
293 public static function reset_cache() {
294 self::$home_probe = null;
295 self::$tls_info = null;
296 }
297
298 /**
299 * Human-readable relative time for "last scan" display.
300 *
301 * @param int $timestamp Unix timestamp.
302 * @return string
303 */
304 public static function human_time_diff( $timestamp ) {
305 if ( $timestamp <= 0 ) {
306 return __( 'Never', 'vigilante' );
307 }
308 return sprintf(
309 /* translators: %s: human-readable duration */
310 __( '%s ago', 'vigilante' ),
311 human_time_diff( $timestamp, time() )
312 );
313 }
314
315 /**
316 * Simple filesystem existence test with URL-based fallback. Some hosts hide
317 * files that exist on disk, so file_exists is the authoritative local check
318 * and the URL probe is a defensive secondary signal.
319 *
320 * @param string $relative Path relative to ABSPATH (e.g. "readme.html").
321 * @return bool
322 */
323 public static function abspath_file_exists( $relative ) {
324 $path = trailingslashit( ABSPATH ) . ltrim( $relative, '/' );
325 return file_exists( $path );
326 }
327
328 /**
329 * Check whether a public URL returns HTTP 200. Used to confirm exposure of
330 * sensitive files when file_exists flagged them.
331 *
332 * @param string $relative Path relative to site root.
333 * @return bool True if URL returns 2xx.
334 */
335 public static function public_url_returns_ok( $relative ) {
336 $url = trailingslashit( home_url() ) . ltrim( $relative, '/' );
337 $response = self::get( $url, array( 'redirection' => 0 ) );
338 if ( is_wp_error( $response ) ) {
339 return false;
340 }
341 $code = (int) wp_remote_retrieve_response_code( $response );
342 return $code >= 200 && $code < 300;
343 }
344 }
345