PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.2
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.2
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
← All changes | includes/class-security-analyzer.php +119 -147 3.0.5 → 3.0.2 View file →
@@ -26,23 +26,26 @@
26 26 const OPTION_LAST_SCAN = 'vigilante_analyzer_last_scan';
27 27 const OPTION_HISTORY = 'vigilante_analyzer_history';
28 28 const OPTION_FIX_LOG = 'vigilante_analyzer_fix_log';
29 29 const HISTORY_LIMIT = 30;
30 + // Legacy constant kept for backward compatibility. The real source of truth
31 + // is total_max_points(), which sums the declared "max" of every category
32 + // in get_categories(). Bump this only when manually verifying the sum.
33 + const TOTAL_MAX_POINTS = 106;
30 34 const REGRESSION_THRESHOLD = 10; // Points dropped before sending the alert email.
31 35 /*
32 36 * Highest score a site can show while the self-protection check reports
33 37 * tampering: every other result of this report was computed by the same
34 38 * code whose files were reported as changed, so a high number next to it
35 - * would be a lie. 29 sits inside grade E of
36 - * Vigilante_SA_Helpers::grade_for_score().
39 + * would be a lie. 29 is the top of grade E in compute_grade().
37 40 */
38 41 const SCORE_CAP_ON_TAMPER = 29;
39 42
40 43 /**
41 - * Sum of the declared "max" of every category: what a scan earns when
42 - * every check can be measured. It is what the widget shows before the
43 - * first scan. A report is graded against what it did measure, which
44 - * summarize_categories() adds up from the checks themselves.
44 + * Sum of the declared "max" of every category. This is the canonical
45 + * maximum a scan can earn. Using this instead of the constant prevents
46 + * the desync we hit in 2.6.0 (a new check raised the internal category
47 + * max but the global total stayed at the pre-bump value).
45 48 *
46 49 * @return int
47 50 */
48 51 public static function total_max_points() {
@@ -82,17 +85,14 @@
82 85 ),
83 86 'wp_exposure' => array(
84 87 'slug' => 'wp_exposure',
85 88 'label' => __( 'WordPress Exposure', 'vigilante' ),
86 - // 18 -> 13: the active theme row is informational now. Whether
87 - // the theme has an update pending is already scored by the
88 - // theme_updates check of the internal category.
89 - 'max' => 13,
89 + 'max' => 18,
90 90 ),
91 91 'access' => array(
92 92 'slug' => 'access',
93 93 'label' => __( 'Access & Authentication', 'vigilante' ),
94 - 'max' => 18,
94 + 'max' => 20,
95 95 ),
96 96 'files' => array(
97 97 'slug' => 'files',
98 98 'label' => __( 'Sensitive Files', 'vigilante' ),
@@ -199,16 +199,10 @@
199 199 $this->persist_scan( $report, $phase );
200 200
201 201 // Push to history only when the whole scan has finished (either 'all' in one call,
202 202 // or a 'slow' phase that immediately follows a 'fast' phase within the same minute).
203 - if ( 'all' === $phase ) {
203 + if ( 'all' === $phase || 'slow' === $phase ) {
204 204 $this->push_history( $report );
205 - } elseif ( 'slow' === $phase ) {
206 - // The slow phase closes a scan the fast one started, so what goes
207 - // to the history is the two merged, as persist_scan() has just
208 - // stored them. The report of this phase alone carries only its own
209 - // checks, and the trend showed every manual scan as a collapse.
210 - $this->push_history( $this->get_last_scan() );
211 205 }
212 206
213 207 return $report;
214 208 }
@@ -221,14 +215,16 @@
221 215 * @param int $started Unix timestamp.
222 216 * @return array
223 217 */
224 218 private function build_report( $results, $phase, $started ) {
219 + $cat_meta = self::get_categories();
220 +
225 221 $categories = array();
226 - foreach ( self::get_categories() as $slug => $meta ) {
222 + foreach ( $cat_meta as $slug => $meta ) {
227 223 $categories[ $slug ] = array(
228 224 'slug' => $slug,
229 225 'label' => $meta['label'],
230 - 'max' => 0,
226 + 'max' => $meta['max'],
231 227 'earned' => 0,
232 228 'checks' => array(),
233 229 'counts' => array(
234 230 'pass' => 0,
@@ -239,21 +235,70 @@
239 235 ),
240 236 );
241 237 }
242 238
239 + $total_earned = 0;
240 + $total_max = 0;
241 + $counts = array(
242 + 'pass' => 0,
243 + 'warn' => 0,
244 + 'fail' => 0,
245 + 'info' => 0,
246 + 'skip' => 0,
247 + );
248 +
243 249 foreach ( $results as $r ) {
244 - if ( ! ( $r instanceof Vigilante_SA_Check_Result ) || ! isset( $categories[ $r->category ] ) ) {
250 + if ( ! ( $r instanceof Vigilante_SA_Check_Result ) ) {
245 251 continue;
246 252 }
247 - $categories[ $r->category ]['checks'][] = $r->to_array();
253 + $slug = $r->category;
254 + if ( ! isset( $categories[ $slug ] ) ) {
255 + continue;
256 + }
257 + $categories[ $slug ]['checks'][] = $r->to_array();
258 + $categories[ $slug ]['counts'][ $r->state ] = isset( $categories[ $slug ]['counts'][ $r->state ] )
259 + ? $categories[ $slug ]['counts'][ $r->state ] + 1
260 + : 1;
261 + $counts[ $r->state ] = isset( $counts[ $r->state ] ) ? $counts[ $r->state ] + 1 : 1;
262 +
263 + if ( $r->counts_for_score() ) {
264 + $categories[ $slug ]['earned'] += $r->score;
265 + $total_earned += $r->score;
266 + $total_max += $r->max;
267 + }
248 268 }
249 269
250 - return array_merge(
251 - array(
252 - 'ran_at' => $started,
253 - 'phase' => $phase,
254 - ),
255 - self::summarize_categories( $categories )
270 + // Normalize against the declared category max so skipped checks don't erode the score.
271 + $declared_max = self::total_max_points();
272 + $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $declared_max );
273 +
274 + // Self-protection caps the score (see SCORE_CAP_ON_TAMPER). The same
275 + // cap is applied when phases are merged, in rebuild_from_categories().
276 + $capped_by = '';
277 + foreach ( $results as $r ) {
278 + if ( $r instanceof Vigilante_SA_Check_Result
279 + && 'self_integrity' === $r->id
280 + && Vigilante_SA_Check_Result::STATE_FAIL === $r->state ) {
281 + $capped_by = 'self_integrity';
282 + break;
283 + }
284 + }
285 + if ( 'self_integrity' === $capped_by && $grade['score'] > self::SCORE_CAP_ON_TAMPER ) {
286 + $grade['score'] = self::SCORE_CAP_ON_TAMPER;
287 + $grade['grade'] = 'E';
288 + }
289 +
290 + return array(
291 + 'ran_at' => $started,
292 + 'phase' => $phase,
293 + 'total_earned' => $total_earned,
294 + 'total_max' => $declared_max,
295 + 'total_evaluated'=> $total_max, // Actual evaluated max (excluding skipped).
296 + 'score' => $grade['score'],
297 + 'grade' => $grade['grade'],
298 + 'capped_by' => $capped_by,
299 + 'counts' => $counts,
300 + 'categories' => $categories,
256 301 );
257 302 }
258 303
259 304 /**
@@ -306,12 +351,10 @@
306 351 }
307 352 $existing['categories'][ $slug ]['checks'] = $merged_checks;
308 353 }
309 354
310 - // Recompute totals from the merged categories, with the ones that have
311 - // not run yet in their place: the loop above only copies those that
312 - // came with checks.
313 - $rebuilt = self::summarize_categories( self::with_every_category( $existing['categories'] ) );
355 + // Recompute totals from the merged categories.
356 + $rebuilt = $this->rebuild_from_categories( $existing['categories'] );
314 357 $existing = array_merge( $existing, $rebuilt );
315 358 $existing['ran_at'] = $report['ran_at'];
316 359 $existing['phase'] = $phase;
317 360 $existing['elapsed'] = isset( $report['elapsed'] ) ? $report['elapsed'] : 0;
@@ -319,38 +362,17 @@
319 362 update_option( self::OPTION_LAST_SCAN, $existing, false );
320 363 }
321 364
322 365 /**
323 - * Totals, counts and grade of a set of categories, added up from their checks
366 + * Recompute totals and grade from a categories array (used when merging phases).
324 367 *
325 - * The one place where a report is scored: a fresh scan, two phases merged
326 - * and a stored report read back all go through here.
327 - *
328 - * A check that could not be measured (skip) and one that is only
329 - * informative (info) are left out of both sides of the fraction, in their
330 - * category and in the total. Until 3.0.4 the score was divided by the
331 - * declared maximum of every category, so each of them counted as a zero:
332 - * a probe that timed out lowered the score as if the check had failed, and
333 - * a site that had switched COOP off on purpose could not reach 100. The
334 - * maximum of a category is therefore the sum of the checks it did score,
335 - * which also means it cannot drift from them when a check is added.
336 - *
337 - * A category none of whose checks has run yet is another matter: the scan
338 - * is not finished. It counts with its declared maximum and nothing earned,
339 - * or a first scan whose remote phase never arrives (a timeout, a closed
340 - * tab) would be graded over the local checks alone and read better than a
341 - * finished one.
342 - *
343 - * @since 3.0.5
344 - *
345 - * @param array $categories Categories with their checks.
346 - * @return array categories, total_earned, total_max, total_evaluated, score, grade, capped_by, counts.
368 + * @param array $categories
369 + * @return array subset with total_earned, total_max, total_evaluated, score, grade, counts.
347 370 */
348 - public static function summarize_categories( array $categories ) {
349 - $total_earned = 0;
350 - $total_max = 0;
351 - $capped_by = '';
352 - $counts = array(
371 + private function rebuild_from_categories( array $categories ) {
372 + $total_earned = 0;
373 + $total_evaluated = 0;
374 + $counts = array(
353 375 'pass' => 0,
354 376 'warn' => 0,
355 377 'fail' => 0,
356 378 'info' => 0,
@@ -356,20 +378,16 @@
356 378 'info' => 0,
357 379 'skip' => 0,
358 380 );
359 381
360 - $declared = self::get_categories();
361 - $started = false;
362 - foreach ( $categories as $cat ) {
363 - if ( is_array( $cat ) && ! empty( $cat['checks'] ) ) {
364 - $started = true;
365 - break;
366 - }
367 - }
382 + // Resolve the canonical "max" of every category from the declared meta.
383 + // Without this, merging fast/slow phases preserves a stale "max" from
384 + // the previous scan, which is what produced the 28/22 desync after
385 + // 2.6.0 added the closed_plugins check.
386 + $cat_meta = self::get_categories();
368 387
369 388 foreach ( $categories as $slug => $cat ) {
370 389 $cat_earned = 0;
371 - $cat_max = 0;
372 390 $cat_counts = array(
373 391 'pass' => 0,
374 392 'warn' => 0,
375 393 'fail' => 0,
@@ -375,97 +393,62 @@
375 393 'fail' => 0,
376 394 'info' => 0,
377 395 'skip' => 0,
378 396 );
379 - $checks = ( is_array( $cat ) && isset( $cat['checks'] ) ) ? (array) $cat['checks'] : array();
380 -
381 - // Not run yet, in a scan that has started: pending, in full.
382 - if ( empty( $checks ) && $started && isset( $declared[ $slug ] ) && empty( $declared[ $slug ]['info_only'] ) ) {
383 - $cat_max = (int) $declared[ $slug ]['max'];
384 - }
385 -
386 - foreach ( $checks as $c ) {
387 - $state = isset( $c['state'] ) ? (string) $c['state'] : Vigilante_SA_Check_Result::STATE_SKIP;
397 + foreach ( (array) $cat['checks'] as $c ) {
398 + $state = isset( $c['state'] ) ? $c['state'] : Vigilante_SA_Check_Result::STATE_SKIP;
388 399 $cat_counts[ $state ] = isset( $cat_counts[ $state ] ) ? $cat_counts[ $state ] + 1 : 1;
389 400 $counts[ $state ] = isset( $counts[ $state ] ) ? $counts[ $state ] + 1 : 1;
390 401
391 - // Self-protection caps the score (see SCORE_CAP_ON_TAMPER).
392 - if ( 'self_integrity' === ( isset( $c['id'] ) ? $c['id'] : '' )
393 - && Vigilante_SA_Check_Result::STATE_FAIL === $state ) {
394 - $capped_by = 'self_integrity';
395 - }
396 -
397 402 if ( Vigilante_SA_Check_Result::STATE_INFO === $state || Vigilante_SA_Check_Result::STATE_SKIP === $state ) {
398 403 continue;
399 404 }
400 - $cat_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
401 - $cat_max += isset( $c['max'] ) ? (int) $c['max'] : 0;
405 + $cat_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
406 + $total_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
407 + $total_evaluated += isset( $c['max'] ) ? (int) $c['max'] : 0;
402 408 }
403 -
404 - if ( ! is_array( $cat ) ) {
405 - $categories[ $slug ] = array();
406 - }
407 409 $categories[ $slug ]['earned'] = $cat_earned;
408 - $categories[ $slug ]['max'] = $cat_max;
409 410 $categories[ $slug ]['counts'] = $cat_counts;
410 411
411 - $total_earned += $cat_earned;
412 - $total_max += $cat_max;
412 + // Force the canonical max so old cached scans get repaired the
413 + // moment they're touched (no need to wait for a clean "all" phase).
414 + if ( isset( $cat_meta[ $slug ]['max'] ) ) {
415 + $categories[ $slug ]['max'] = (int) $cat_meta[ $slug ]['max'];
416 + }
413 417 }
414 418
415 - $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
419 + $total_max = self::total_max_points();
420 + $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
416 421
422 + // Self-protection caps the score (see SCORE_CAP_ON_TAMPER).
423 + $capped_by = '';
424 + foreach ( $categories as $cat ) {
425 + foreach ( (array) $cat['checks'] as $c ) {
426 + if ( 'self_integrity' === ( isset( $c['id'] ) ? $c['id'] : '' )
427 + && Vigilante_SA_Check_Result::STATE_FAIL === ( isset( $c['state'] ) ? $c['state'] : '' ) ) {
428 + $capped_by = 'self_integrity';
429 + break 2;
430 + }
431 + }
432 + }
417 433 if ( 'self_integrity' === $capped_by && $grade['score'] > self::SCORE_CAP_ON_TAMPER ) {
418 434 $grade['score'] = self::SCORE_CAP_ON_TAMPER;
419 - $grade['grade'] = Vigilante_SA_Helpers::grade_for_score( self::SCORE_CAP_ON_TAMPER );
435 + $grade['grade'] = 'E';
420 436 }
421 437
422 438 return array(
423 - 'categories' => $categories,
424 - 'total_earned' => $total_earned,
425 - 'total_max' => $total_max,
426 - // Same figure as total_max since 3.0.5, kept for whoever reads it.
427 - 'total_evaluated' => $total_max,
428 - 'score' => $grade['score'],
429 - 'grade' => $grade['grade'],
430 - 'capped_by' => $capped_by,
431 - 'counts' => $counts,
439 + 'categories' => $categories,
440 + 'total_earned' => $total_earned,
441 + 'total_max' => $total_max,
442 + 'total_evaluated'=> $total_evaluated,
443 + 'score' => $grade['score'],
444 + 'grade' => $grade['grade'],
445 + 'capped_by' => $capped_by,
446 + 'counts' => $counts,
432 447 );
433 448 }
434 449
435 450 /**
436 - * The categories of a report, with every declared one present
437 - *
438 - * A report stored after the local phase alone only holds the categories
439 - * that phase filled, and summarize_categories() can only count as pending
440 - * a category it is given. Without this, a first scan whose remote phase
441 - * never arrived was graded over the four categories it had: 100 over 85
442 - * points where the whole report gives 75 over 113. Found by the second
443 - * cross review of 3.0.5.
444 - *
445 - * @since 3.0.5
446 - *
447 - * @param array $categories Categories of a stored or merged report.
448 - * @return array The same, plus an empty entry for each one that was missing.
449 - */
450 - private static function with_every_category( array $categories ) {
451 - foreach ( self::get_categories() as $slug => $meta ) {
452 - if ( isset( $categories[ $slug ] ) && is_array( $categories[ $slug ] ) ) {
453 - continue;
454 - }
455 - $categories[ $slug ] = array(
456 - 'slug' => $slug,
457 - 'label' => $meta['label'],
458 - 'max' => 0,
459 - 'earned' => 0,
460 - 'checks' => array(),
461 - );
462 - }
463 -
464 - return $categories;
465 - }
466 -
467 - /**
468 451 * Append a minimal history entry (no per-check detail) to the circular buffer.
469 452 *
470 453 * @param array $report
471 454 */
@@ -519,19 +502,8 @@
519 502 ),
520 503 'categories' => array(),
521 504 );
522 505 }
523 -
524 - // A stored report is read back under the rules in force: its checks
525 - // are the ones that ran, and what they add up to and the letter they
526 - // earn are worked out now. Otherwise the widget would show the sum
527 - // and the letter of the version that stored it until the next scan,
528 - // and the weekly comparison would set a score against one worked out
529 - // differently.
530 - if ( isset( $raw['categories'] ) && is_array( $raw['categories'] ) ) {
531 - $raw = array_merge( $raw, self::summarize_categories( self::with_every_category( $raw['categories'] ) ) );
532 - }
533 -
534 506 return $raw;
535 507 }
536 508
537 509 /**