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
← All changes | includes/class-security-analyzer.php +140 -118 3.0.4 → 3.0.5 View file →
@@ -26,26 +26,23 @@
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;
34 30 const REGRESSION_THRESHOLD = 10; // Points dropped before sending the alert email.
35 31 /*
36 32 * Highest score a site can show while the self-protection check reports
37 33 * tampering: every other result of this report was computed by the same
38 34 * code whose files were reported as changed, so a high number next to it
39 - * would be a lie. 29 is the top of grade E in compute_grade().
35 + * would be a lie. 29 sits inside grade E of
36 + * Vigilante_SA_Helpers::grade_for_score().
40 37 */
41 38 const SCORE_CAP_ON_TAMPER = 29;
42 39
43 40 /**
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).
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.
48 45 *
49 46 * @return int
50 47 */
51 48 public static function total_max_points() {
@@ -85,14 +82,17 @@
85 82 ),
86 83 'wp_exposure' => array(
87 84 'slug' => 'wp_exposure',
88 85 'label' => __( 'WordPress Exposure', 'vigilante' ),
89 - 'max' => 18,
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,
90 90 ),
91 91 'access' => array(
92 92 'slug' => 'access',
93 93 'label' => __( 'Access & Authentication', 'vigilante' ),
94 - 'max' => 20,
94 + 'max' => 18,
95 95 ),
96 96 'files' => array(
97 97 'slug' => 'files',
98 98 'label' => __( 'Sensitive Files', 'vigilante' ),
@@ -221,16 +221,14 @@
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 -
227 225 $categories = array();
228 - foreach ( $cat_meta as $slug => $meta ) {
226 + foreach ( self::get_categories() as $slug => $meta ) {
229 227 $categories[ $slug ] = array(
230 228 'slug' => $slug,
231 229 'label' => $meta['label'],
232 - 'max' => $meta['max'],
230 + 'max' => 0,
233 231 'earned' => 0,
234 232 'checks' => array(),
235 233 'counts' => array(
236 234 'pass' => 0,
@@ -241,70 +239,21 @@
241 239 ),
242 240 );
243 241 }
244 242
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 -
255 243 foreach ( $results as $r ) {
256 - if ( ! ( $r instanceof Vigilante_SA_Check_Result ) ) {
244 + if ( ! ( $r instanceof Vigilante_SA_Check_Result ) || ! isset( $categories[ $r->category ] ) ) {
257 245 continue;
258 246 }
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 - }
247 + $categories[ $r->category ]['checks'][] = $r->to_array();
274 248 }
275 249
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,
250 + return array_merge(
251 + array(
252 + 'ran_at' => $started,
253 + 'phase' => $phase,
254 + ),
255 + self::summarize_categories( $categories )
307 256 );
308 257 }
309 258
310 259 /**
@@ -357,10 +306,12 @@
357 306 }
358 307 $existing['categories'][ $slug ]['checks'] = $merged_checks;
359 308 }
360 309
361 - // Recompute totals from the merged categories.
362 - $rebuilt = $this->rebuild_from_categories( $existing['categories'] );
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'] ) );
363 314 $existing = array_merge( $existing, $rebuilt );
364 315 $existing['ran_at'] = $report['ran_at'];
365 316 $existing['phase'] = $phase;
366 317 $existing['elapsed'] = isset( $report['elapsed'] ) ? $report['elapsed'] : 0;
@@ -368,17 +319,38 @@
368 319 update_option( self::OPTION_LAST_SCAN, $existing, false );
369 320 }
370 321
371 322 /**
372 - * Recompute totals and grade from a categories array (used when merging phases).
323 + * Totals, counts and grade of a set of categories, added up from their checks
373 324 *
374 - * @param array $categories
375 - * @return array subset with total_earned, total_max, total_evaluated, score, grade, counts.
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.
376 347 */
377 - private function rebuild_from_categories( array $categories ) {
378 - $total_earned = 0;
379 - $total_evaluated = 0;
380 - $counts = array(
348 + public static function summarize_categories( array $categories ) {
349 + $total_earned = 0;
350 + $total_max = 0;
351 + $capped_by = '';
352 + $counts = array(
381 353 'pass' => 0,
382 354 'warn' => 0,
383 355 'fail' => 0,
384 356 'info' => 0,
@@ -384,16 +356,20 @@
384 356 'info' => 0,
385 357 'skip' => 0,
386 358 );
387 359
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();
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 + }
393 368
394 369 foreach ( $categories as $slug => $cat ) {
395 370 $cat_earned = 0;
371 + $cat_max = 0;
396 372 $cat_counts = array(
397 373 'pass' => 0,
398 374 'warn' => 0,
399 375 'fail' => 0,
@@ -399,62 +375,97 @@
399 375 'fail' => 0,
400 376 'info' => 0,
401 377 'skip' => 0,
402 378 );
403 - foreach ( (array) $cat['checks'] as $c ) {
404 - $state = isset( $c['state'] ) ? $c['state'] : Vigilante_SA_Check_Result::STATE_SKIP;
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;
405 388 $cat_counts[ $state ] = isset( $cat_counts[ $state ] ) ? $cat_counts[ $state ] + 1 : 1;
406 389 $counts[ $state ] = isset( $counts[ $state ] ) ? $counts[ $state ] + 1 : 1;
407 390
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 +
408 397 if ( Vigilante_SA_Check_Result::STATE_INFO === $state || Vigilante_SA_Check_Result::STATE_SKIP === $state ) {
409 398 continue;
410 399 }
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;
400 + $cat_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
401 + $cat_max += isset( $c['max'] ) ? (int) $c['max'] : 0;
414 402 }
403 +
404 + if ( ! is_array( $cat ) ) {
405 + $categories[ $slug ] = array();
406 + }
415 407 $categories[ $slug ]['earned'] = $cat_earned;
408 + $categories[ $slug ]['max'] = $cat_max;
416 409 $categories[ $slug ]['counts'] = $cat_counts;
417 410
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 - }
411 + $total_earned += $cat_earned;
412 + $total_max += $cat_max;
423 413 }
424 414
425 - $total_max = self::total_max_points();
426 - $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
415 + $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
427 416
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 - }
439 417 if ( 'self_integrity' === $capped_by && $grade['score'] > self::SCORE_CAP_ON_TAMPER ) {
440 418 $grade['score'] = self::SCORE_CAP_ON_TAMPER;
441 - $grade['grade'] = 'E';
419 + $grade['grade'] = Vigilante_SA_Helpers::grade_for_score( self::SCORE_CAP_ON_TAMPER );
442 420 }
443 421
444 422 return array(
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,
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,
453 432 );
454 433 }
455 434
456 435 /**
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 + /**
457 468 * Append a minimal history entry (no per-check detail) to the circular buffer.
458 469 *
459 470 * @param array $report
460 471 */
@@ -508,8 +519,19 @@
508 519 ),
509 520 'categories' => array(),
510 521 );
511 522 }
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 +
512 534 return $raw;
513 535 }
514 536
515 537 /**