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 +160 -89 2.11.6 → 3.0.5 View file →
@@ -26,19 +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.
31 + /*
32 + * Highest score a site can show while the self-protection check reports
33 + * tampering: every other result of this report was computed by the same
34 + * 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().
37 + */
38 + const SCORE_CAP_ON_TAMPER = 29;
35 39
36 40 /**
37 - * Sum of the declared "max" of every category. This is the canonical
38 - * maximum a scan can earn. Using this instead of the constant prevents
39 - * the desync we hit in 2.6.0 (a new check raised the internal category
40 - * 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.
41 45 *
42 46 * @return int
43 47 */
44 48 public static function total_max_points() {
@@ -78,14 +82,17 @@
78 82 ),
79 83 'wp_exposure' => array(
80 84 'slug' => 'wp_exposure',
81 85 'label' => __( 'WordPress Exposure', 'vigilante' ),
82 - '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,
83 90 ),
84 91 'access' => array(
85 92 'slug' => 'access',
86 93 'label' => __( 'Access & Authentication', 'vigilante' ),
87 - 'max' => 20,
94 + 'max' => 18,
88 95 ),
89 96 'files' => array(
90 97 'slug' => 'files',
91 98 'label' => __( 'Sensitive Files', 'vigilante' ),
@@ -96,9 +103,11 @@
96 103 'label' => __( 'Internal Checks (exclusive)', 'vigilante' ),
97 104 // Sum of the max values of every check in
98 105 // Vigilante_SA_Category_Internal. Update when adding/removing
99 106 // checks or changing their max value.
100 - 'max' => 30,
107 + // 3.0.0: 30 -> 40 (self_integrity check added, worth 10: the
108 + // integrity of the plugin that runs every other check).
109 + 'max' => 40,
101 110 ),
102 111 'reputation' => array(
103 112 'slug' => 'reputation',
104 113 'label' => __( 'Reputation / Blacklists', 'vigilante' ),
@@ -190,10 +199,16 @@
190 199 $this->persist_scan( $report, $phase );
191 200
192 201 // Push to history only when the whole scan has finished (either 'all' in one call,
193 202 // or a 'slow' phase that immediately follows a 'fast' phase within the same minute).
194 - if ( 'all' === $phase || 'slow' === $phase ) {
203 + if ( 'all' === $phase ) {
195 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() );
196 211 }
197 212
198 213 return $report;
199 214 }
@@ -206,16 +221,14 @@
206 221 * @param int $started Unix timestamp.
207 222 * @return array
208 223 */
209 224 private function build_report( $results, $phase, $started ) {
210 - $cat_meta = self::get_categories();
211 -
212 225 $categories = array();
213 - foreach ( $cat_meta as $slug => $meta ) {
226 + foreach ( self::get_categories() as $slug => $meta ) {
214 227 $categories[ $slug ] = array(
215 228 'slug' => $slug,
216 229 'label' => $meta['label'],
217 - 'max' => $meta['max'],
230 + 'max' => 0,
218 231 'earned' => 0,
219 232 'checks' => array(),
220 233 'counts' => array(
221 234 'pass' => 0,
@@ -226,53 +239,21 @@
226 239 ),
227 240 );
228 241 }
229 242
230 - $total_earned = 0;
231 - $total_max = 0;
232 - $counts = array(
233 - 'pass' => 0,
234 - 'warn' => 0,
235 - 'fail' => 0,
236 - 'info' => 0,
237 - 'skip' => 0,
238 - );
239 -
240 243 foreach ( $results as $r ) {
241 - if ( ! ( $r instanceof Vigilante_SA_Check_Result ) ) {
244 + if ( ! ( $r instanceof Vigilante_SA_Check_Result ) || ! isset( $categories[ $r->category ] ) ) {
242 245 continue;
243 246 }
244 - $slug = $r->category;
245 - if ( ! isset( $categories[ $slug ] ) ) {
246 - continue;
247 - }
248 - $categories[ $slug ]['checks'][] = $r->to_array();
249 - $categories[ $slug ]['counts'][ $r->state ] = isset( $categories[ $slug ]['counts'][ $r->state ] )
250 - ? $categories[ $slug ]['counts'][ $r->state ] + 1
251 - : 1;
252 - $counts[ $r->state ] = isset( $counts[ $r->state ] ) ? $counts[ $r->state ] + 1 : 1;
253 -
254 - if ( $r->counts_for_score() ) {
255 - $categories[ $slug ]['earned'] += $r->score;
256 - $total_earned += $r->score;
257 - $total_max += $r->max;
258 - }
247 + $categories[ $r->category ]['checks'][] = $r->to_array();
259 248 }
260 249
261 - // Normalize against the declared category max so skipped checks don't erode the score.
262 - $declared_max = self::total_max_points();
263 - $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $declared_max );
264 -
265 - return array(
266 - 'ran_at' => $started,
267 - 'phase' => $phase,
268 - 'total_earned' => $total_earned,
269 - 'total_max' => $declared_max,
270 - 'total_evaluated'=> $total_max, // Actual evaluated max (excluding skipped).
271 - 'score' => $grade['score'],
272 - 'grade' => $grade['grade'],
273 - 'counts' => $counts,
274 - 'categories' => $categories,
250 + return array_merge(
251 + array(
252 + 'ran_at' => $started,
253 + 'phase' => $phase,
254 + ),
255 + self::summarize_categories( $categories )
275 256 );
276 257 }
277 258
278 259 /**
@@ -325,10 +306,12 @@
325 306 }
326 307 $existing['categories'][ $slug ]['checks'] = $merged_checks;
327 308 }
328 309
329 - // Recompute totals from the merged categories.
330 - $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'] ) );
331 314 $existing = array_merge( $existing, $rebuilt );
332 315 $existing['ran_at'] = $report['ran_at'];
333 316 $existing['phase'] = $phase;
334 317 $existing['elapsed'] = isset( $report['elapsed'] ) ? $report['elapsed'] : 0;
@@ -336,17 +319,38 @@
336 319 update_option( self::OPTION_LAST_SCAN, $existing, false );
337 320 }
338 321
339 322 /**
340 - * 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
341 324 *
342 - * @param array $categories
343 - * @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.
344 347 */
345 - private function rebuild_from_categories( array $categories ) {
346 - $total_earned = 0;
347 - $total_evaluated = 0;
348 - $counts = array(
348 + public static function summarize_categories( array $categories ) {
349 + $total_earned = 0;
350 + $total_max = 0;
351 + $capped_by = '';
352 + $counts = array(
349 353 'pass' => 0,
350 354 'warn' => 0,
351 355 'fail' => 0,
352 356 'info' => 0,
@@ -352,16 +356,20 @@
352 356 'info' => 0,
353 357 'skip' => 0,
354 358 );
355 359
356 - // Resolve the canonical "max" of every category from the declared meta.
357 - // Without this, merging fast/slow phases preserves a stale "max" from
358 - // the previous scan, which is what produced the 28/22 desync after
359 - // 2.6.0 added the closed_plugins check.
360 - $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 + }
361 368
362 369 foreach ( $categories as $slug => $cat ) {
363 370 $cat_earned = 0;
371 + $cat_max = 0;
364 372 $cat_counts = array(
365 373 'pass' => 0,
366 374 'warn' => 0,
367 375 'fail' => 0,
@@ -367,45 +375,97 @@
367 375 'fail' => 0,
368 376 'info' => 0,
369 377 'skip' => 0,
370 378 );
371 - foreach ( (array) $cat['checks'] as $c ) {
372 - $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;
373 388 $cat_counts[ $state ] = isset( $cat_counts[ $state ] ) ? $cat_counts[ $state ] + 1 : 1;
374 389 $counts[ $state ] = isset( $counts[ $state ] ) ? $counts[ $state ] + 1 : 1;
375 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 +
376 397 if ( Vigilante_SA_Check_Result::STATE_INFO === $state || Vigilante_SA_Check_Result::STATE_SKIP === $state ) {
377 398 continue;
378 399 }
379 - $cat_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
380 - $total_earned += isset( $c['score'] ) ? (int) $c['score'] : 0;
381 - $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;
382 402 }
403 +
404 + if ( ! is_array( $cat ) ) {
405 + $categories[ $slug ] = array();
406 + }
383 407 $categories[ $slug ]['earned'] = $cat_earned;
408 + $categories[ $slug ]['max'] = $cat_max;
384 409 $categories[ $slug ]['counts'] = $cat_counts;
385 410
386 - // Force the canonical max so old cached scans get repaired the
387 - // moment they're touched (no need to wait for a clean "all" phase).
388 - if ( isset( $cat_meta[ $slug ]['max'] ) ) {
389 - $categories[ $slug ]['max'] = (int) $cat_meta[ $slug ]['max'];
390 - }
411 + $total_earned += $cat_earned;
412 + $total_max += $cat_max;
391 413 }
392 414
393 - $total_max = self::total_max_points();
394 - $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
415 + $grade = Vigilante_SA_Helpers::compute_grade( $total_earned, $total_max );
395 416
417 + if ( 'self_integrity' === $capped_by && $grade['score'] > self::SCORE_CAP_ON_TAMPER ) {
418 + $grade['score'] = self::SCORE_CAP_ON_TAMPER;
419 + $grade['grade'] = Vigilante_SA_Helpers::grade_for_score( self::SCORE_CAP_ON_TAMPER );
420 + }
421 +
396 422 return array(
397 - 'categories' => $categories,
398 - 'total_earned' => $total_earned,
399 - 'total_max' => $total_max,
400 - 'total_evaluated'=> $total_evaluated,
401 - 'score' => $grade['score'],
402 - 'grade' => $grade['grade'],
403 - '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,
404 432 );
405 433 }
406 434
407 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 + /**
408 468 * Append a minimal history entry (no per-check detail) to the circular buffer.
409 469 *
410 470 * @param array $report
411 471 */
@@ -459,8 +519,19 @@
459 519 ),
460 520 'categories' => array(),
461 521 );
462 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 +
463 534 return $raw;
464 535 }
465 536
466 537 /**