| @@ -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 | /** |