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
← All changes | includes/class-security-analyzer.php +118 -140 3.0.5 → 3.0.4 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' ),
@@ -221,14 +221,16 @@
221 221 * @param int $started Unix timestamp.
222 222 * @return array
223 223 */
224 224 private function build_report( $results, $phase, $started ) {
225 + $cat_meta = self::get_categories();
226 +
225 227 $categories = array();
226 - foreach ( self::get_categories() as $slug => $meta ) {
228 + foreach ( $cat_meta as $slug => $meta ) {
227 229 $categories[ $slug ] = array(
228 230 'slug' => $slug,
229 231 'label' => $meta['label'],
230 - 'max' => 0,
232 + 'max' => $meta['max'],
231 233 'earned' => 0,
232 234 'checks' => array(),
233 235 'counts' => array(
234 236 'pass' => 0,
@@ -239,21 +241,70 @@
239 241 ),
240 242 );
241 243 }
242 244
245 + $total_earned = 0;
246 + $total_max = 0;
247 + $counts = array(
248 + 'pass' => 0,
249 + 'warn' => 0,
250 + 'fail' => 0,
251 + 'info' => 0,
252 + 'skip' => 0,
253 + );
254 +
243 255 foreach ( $results as $r ) {
244 - if ( ! ( $r instanceof Vigilante_SA_Check_Result ) || ! isset( $categories[ $r->category ] ) ) {
256 + if ( ! ( $r instanceof Vigilante_SA_Check_Result ) ) {
245 257 continue;
246 258 }
247 - $categories[ $r->category ]['checks'][] = $r->to_array();
259 + $slug = $r->category;
260 + if ( ! isset( $categories[ $slug ] ) ) {
261 + continue;
262 + }
263 + $categories[ $slug ]['checks'][] = $r->to_array();
264 + $categories[ $slug ]['counts'][ $r->state ] = isset( $categories[ $slug ]['counts'][ $r->state ] )
265 + ? $categories[ $slug ]['counts'][ $r->state ] + 1
266 + : 1;
267 + $counts[ $r->state ] = isset( $counts[ $r->state ] ) ? $counts[ $r->state ] + 1 : 1;
268 +
269 + if ( $r->counts_for_score() ) {
270 + $categories[ $slug ]['earned'] += $r->score;
271 + $total_earned += $r->score;
272 + $total_max += $r->max;
273 + }
248 274 }
249 275
250 - return array_merge(
251 - array(
252 - 'ran_at' => $started,
253 - 'phase' => $phase,
254 - ),
255 - self::summarize_categories( $categories )
276 + // Normalize against the declared category max so skipped checks don't erode the score.
277 + $declared_max = self::total_max_points();
278 + $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $declared_max );
279 +
280 + // Self-protection caps the score (see SCORE_CAP_ON_TAMPER). The same
281 + // cap is applied when phases are merged, in rebuild_from_categories().
282 + $capped_by = '';
283 + foreach ( $results as $r ) {
284 + if ( $r instanceof Vigilante_SA_Check_Result
285 + && 'self_integrity' === $r->id
286 + && Vigilante_SA_Check_Result::STATE_FAIL === $r->state ) {
287 + $capped_by = 'self_integrity';
288 + break;
289 + }
290 + }
291 + if ( 'self_integrity' === $capped_by && $grade['score'] > self::SCORE_CAP_ON_TAMPER ) {
292 + $grade['score'] = self::SCORE_CAP_ON_TAMPER;
293 + $grade['grade'] = 'E';
294 + }
295 +
296 + return array(
297 + 'ran_at' => $started,
298 + 'phase' => $phase,
299 + 'total_earned' => $total_earned,
300 + 'total_max' => $declared_max,
301 + 'total_evaluated'=> $total_max, // Actual evaluated max (excluding skipped).
302 + 'score' => $grade['score'],
303 + 'grade' => $grade['grade'],
304 + 'capped_by' => $capped_by,
305 + 'counts' => $counts,
306 + 'categories' => $categories,
256 307 );
257 308 }
258 309
259 310 /**
@@ -306,12 +357,10 @@
306 357 }
307 358 $existing['categories'][ $slug ]['checks'] = $merged_checks;
308 359 }
309 360
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'] ) );
361 + // Recompute totals from the merged categories.
362 + $rebuilt = $this->rebuild_from_categories( $existing['categories'] );
314 363 $existing = array_merge( $existing, $rebuilt );
315 364 $existing['ran_at'] = $report['ran_at'];
316 365 $existing['phase'] = $phase;
317 366 $existing['elapsed'] = isset( $report['elapsed'] ) ? $report['elapsed'] : 0;
@@ -319,38 +368,17 @@
319 368 update_option( self::OPTION_LAST_SCAN, $existing, false );
320 369 }
321 370
322 371 /**
323 - * Totals, counts and grade of a set of categories, added up from their checks
372 + * Recompute totals and grade from a categories array (used when merging phases).
324 373 *
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.
374 + * @param array $categories
375 + * @return array subset with total_earned, total_max, total_evaluated, score, grade, counts.
347 376 */
348 - public static function summarize_categories( array $categories ) {
349 - $total_earned = 0;
350 - $total_max = 0;
351 - $capped_by = '';
352 - $counts = array(
377 + private function rebuild_from_categories( array $categories ) {
378 + $total_earned = 0;
379 + $total_evaluated = 0;
380 + $counts = array(
353 381 'pass' => 0,
354 382 'warn' => 0,
355 383 'fail' => 0,
356 384 'info' => 0,
@@ -356,20 +384,16 @@
356 384 'info' => 0,
357 385 'skip' => 0,
358 386 );
359 387
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 - }
388 + // Resolve the canonical "max" of every category from the declared meta.
389 + // Without this, merging fast/slow phases preserves a stale "max" from
390 + // the previous scan, which is what produced the 28/22 desync after
391 + // 2.6.0 added the closed_plugins check.
392 + $cat_meta = self::get_categories();
368 393
369 394 foreach ( $categories as $slug => $cat ) {
370 395 $cat_earned = 0;
371 - $cat_max = 0;
372 396 $cat_counts = array(
373 397 'pass' => 0,
374 398 'warn' => 0,
375 399 'fail' => 0,
@@ -375,97 +399,62 @@
375 399 'fail' => 0,
376 400 'info' => 0,
377 401 'skip' => 0,
378 402 );
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;
403 + foreach ( (array) $cat['checks'] as $c ) {
404 + $state = isset( $c['state'] ) ? $c['state'] : Vigilante_SA_Check_Result::STATE_SKIP;
388 405 $cat_counts[ $state ] = isset( $cat_counts[ $state ] ) ? $cat_counts[ $state ] + 1 : 1;
389 406 $counts[ $state ] = isset( $counts[ $state ] ) ? $counts[ $state ] + 1 : 1;
390 407
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 408 if ( Vigilante_SA_Check_Result::STATE_INFO === $state || Vigilante_SA_Check_Result::STATE_SKIP === $state ) {
398 409 continue;
399 410 }
400 - $cat_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
401 - $cat_max += isset( $c['max'] ) ? (int) $c['max'] : 0;
411 + $cat_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
412 + $total_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
413 + $total_evaluated += isset( $c['max'] ) ? (int) $c['max'] : 0;
402 414 }
403 -
404 - if ( ! is_array( $cat ) ) {
405 - $categories[ $slug ] = array();
406 - }
407 415 $categories[ $slug ]['earned'] = $cat_earned;
408 - $categories[ $slug ]['max'] = $cat_max;
409 416 $categories[ $slug ]['counts'] = $cat_counts;
410 417
411 - $total_earned += $cat_earned;
412 - $total_max += $cat_max;
418 + // Force the canonical max so old cached scans get repaired the
419 + // moment they're touched (no need to wait for a clean "all" phase).
420 + if ( isset( $cat_meta[ $slug ]['max'] ) ) {
421 + $categories[ $slug ]['max'] = (int) $cat_meta[ $slug ]['max'];
422 + }
413 423 }
414 424
415 - $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
425 + $total_max = self::total_max_points();
426 + $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
416 427
428 + // Self-protection caps the score (see SCORE_CAP_ON_TAMPER).
429 + $capped_by = '';
430 + foreach ( $categories as $cat ) {
431 + foreach ( (array) $cat['checks'] as $c ) {
432 + if ( 'self_integrity' === ( isset( $c['id'] ) ? $c['id'] : '' )
433 + && Vigilante_SA_Check_Result::STATE_FAIL === ( isset( $c['state'] ) ? $c['state'] : '' ) ) {
434 + $capped_by = 'self_integrity';
435 + break 2;
436 + }
437 + }
438 + }
417 439 if ( 'self_integrity' === $capped_by && $grade['score'] > self::SCORE_CAP_ON_TAMPER ) {
418 440 $grade['score'] = self::SCORE_CAP_ON_TAMPER;
419 - $grade['grade'] = Vigilante_SA_Helpers::grade_for_score( self::SCORE_CAP_ON_TAMPER );
441 + $grade['grade'] = 'E';
420 442 }
421 443
422 444 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,
445 + 'categories' => $categories,
446 + 'total_earned' => $total_earned,
447 + 'total_max' => $total_max,
448 + 'total_evaluated'=> $total_evaluated,
449 + 'score' => $grade['score'],
450 + 'grade' => $grade['grade'],
451 + 'capped_by' => $capped_by,
452 + 'counts' => $counts,
432 453 );
433 454 }
434 455
435 456 /**
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 457 * Append a minimal history entry (no per-check detail) to the circular buffer.
469 458 *
470 459 * @param array $report
471 460 */
@@ -519,19 +508,8 @@
519 508 ),
520 509 'categories' => array(),
521 510 );
522 511 }
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 512 return $raw;
535 513 }
536 514
537 515 /**