PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.4
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.4, at includes/security-analyzer/class-sa-helpers.php

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