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 +147 -119 3.0.2 → 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' ),
@@ -199,10 +199,16 @@
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 || 'slow' === $phase ) {
203 + if ( 'all' === $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() );
205 211 }
206 212
207 213 return $report;
208 214 }
@@ -215,16 +221,14 @@
215 221 * @param int $started Unix timestamp.
216 222 * @return array
217 223 */
218 224 private function build_report( $results, $phase, $started ) {
219 - $cat_meta = self::get_categories();
220 -
221 225 $categories = array();
222 - foreach ( $cat_meta as $slug => $meta ) {
226 + foreach ( self::get_categories() as $slug => $meta ) {
223 227 $categories[ $slug ] = array(
224 228 'slug' => $slug,
225 229 'label' => $meta['label'],
226 - 'max' => $meta['max'],
230 + 'max' => 0,
227 231 'earned' => 0,
228 232 'checks' => array(),
229 233 'counts' => array(
230 234 'pass' => 0,
@@ -235,70 +239,21 @@
235 239 ),
236 240 );
237 241 }
238 242
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 -
249 243 foreach ( $results as $r ) {
250 - if ( ! ( $r instanceof Vigilante_SA_Check_Result ) ) {
244 + if ( ! ( $r instanceof Vigilante_SA_Check_Result ) || ! isset( $categories[ $r->category ] ) ) {
251 245 continue;
252 246 }
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 - }
247 + $categories[ $r->category ]['checks'][] = $r->to_array();
268 248 }
269 249
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,
250 + return array_merge(
251 + array(
252 + 'ran_at' => $started,
253 + 'phase' => $phase,
254 + ),
255 + self::summarize_categories( $categories )
301 256 );
302 257 }
303 258
304 259 /**
@@ -351,10 +306,12 @@
351 306 }
352 307 $existing['categories'][ $slug ]['checks'] = $merged_checks;
353 308 }
354 309
355 - // Recompute totals from the merged categories.
356 - $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'] ) );
357 314 $existing = array_merge( $existing, $rebuilt );
358 315 $existing['ran_at'] = $report['ran_at'];
359 316 $existing['phase'] = $phase;
360 317 $existing['elapsed'] = isset( $report['elapsed'] ) ? $report['elapsed'] : 0;
@@ -362,17 +319,38 @@
362 319 update_option( self::OPTION_LAST_SCAN, $existing, false );
363 320 }
364 321
365 322 /**
366 - * 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
367 324 *
368 - * @param array $categories
369 - * @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.
370 347 */
371 - private function rebuild_from_categories( array $categories ) {
372 - $total_earned = 0;
373 - $total_evaluated = 0;
374 - $counts = array(
348 + public static function summarize_categories( array $categories ) {
349 + $total_earned = 0;
350 + $total_max = 0;
351 + $capped_by = '';
352 + $counts = array(
375 353 'pass' => 0,
376 354 'warn' => 0,
377 355 'fail' => 0,
378 356 'info' => 0,
@@ -378,16 +356,20 @@
378 356 'info' => 0,
379 357 'skip' => 0,
380 358 );
381 359
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();
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 + }
387 368
388 369 foreach ( $categories as $slug => $cat ) {
389 370 $cat_earned = 0;
371 + $cat_max = 0;
390 372 $cat_counts = array(
391 373 'pass' => 0,
392 374 'warn' => 0,
393 375 'fail' => 0,
@@ -393,62 +375,97 @@
393 375 'fail' => 0,
394 376 'info' => 0,
395 377 'skip' => 0,
396 378 );
397 - foreach ( (array) $cat['checks'] as $c ) {
398 - $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;
399 388 $cat_counts[ $state ] = isset( $cat_counts[ $state ] ) ? $cat_counts[ $state ] + 1 : 1;
400 389 $counts[ $state ] = isset( $counts[ $state ] ) ? $counts[ $state ] + 1 : 1;
401 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 +
402 397 if ( Vigilante_SA_Check_Result::STATE_INFO === $state || Vigilante_SA_Check_Result::STATE_SKIP === $state ) {
403 398 continue;
404 399 }
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;
400 + $cat_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
401 + $cat_max += isset( $c['max'] ) ? (int) $c['max'] : 0;
408 402 }
403 +
404 + if ( ! is_array( $cat ) ) {
405 + $categories[ $slug ] = array();
406 + }
409 407 $categories[ $slug ]['earned'] = $cat_earned;
408 + $categories[ $slug ]['max'] = $cat_max;
410 409 $categories[ $slug ]['counts'] = $cat_counts;
411 410
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 - }
411 + $total_earned += $cat_earned;
412 + $total_max += $cat_max;
417 413 }
418 414
419 - $total_max = self::total_max_points();
420 - $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
415 + $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
421 416
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 - }
433 417 if ( 'self_integrity' === $capped_by && $grade['score'] > self::SCORE_CAP_ON_TAMPER ) {
434 418 $grade['score'] = self::SCORE_CAP_ON_TAMPER;
435 - $grade['grade'] = 'E';
419 + $grade['grade'] = Vigilante_SA_Helpers::grade_for_score( self::SCORE_CAP_ON_TAMPER );
436 420 }
437 421
438 422 return array(
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,
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,
447 432 );
448 433 }
449 434
450 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 + /**
451 468 * Append a minimal history entry (no per-check detail) to the circular buffer.
452 469 *
453 470 * @param array $report
454 471 */
@@ -502,8 +519,19 @@
502 519 ),
503 520 'categories' => array(),
504 521 );
505 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 +
506 534 return $raw;
507 535 }
508 536
509 537 /**