$case A hydrated case row (GUARDFORGE_Cases::get_case/list_cases). * @param bool $just_created Whether THIS sync call is the one that opened $case — see * the RECHECK_UNAVAILABLE branch below for why that matters. * @return array{result:string,reason:string}|null Null when this case's type has no recheck path. */ public static function apply( array $case, $just_created = false ) { // Belt and braces: GUARDFORGE_Cases::recheck_open_cases already // filters terminal cases out before calling this, but this method is // also called directly in tests and is cheap to make safe on its own // — a closed case is never reconsidered, full stop. if ( GUARDFORGE_Case_Model::is_terminal( (string) ( $case['status'] ?? '' ) ) ) { return null; } $verdict = self::verdict( $case ); if ( $verdict === null ) { return null; } $repo = GUARDFORGE_Cases::instance(); $result = $verdict['result']; $case_id = (int) $case['id']; if ( $result === GUARDFORGE_Case_Model::RECHECK_FIXED ) { $repo->update_status( $case_id, 'fixed', $verdict['reason'] ); $repo->record_recheck_event( $case_id, 'Recheck: ' . $verdict['reason'], [ 'result' => $result, 'reason' => $verdict['reason'] ] ); if ( class_exists( 'GUARDFORGE_Audit_Log' ) ) { GUARDFORGE_Audit_Log::instance()->record( 'case_fixed', 'case', (string) $case_id, $verdict['reason'] ); } return $verdict; } if ( $result === GUARDFORGE_Case_Model::RECHECK_UNAVAILABLE ) { // Two things this must never do, both found by §6.4 review round 1: // 1. Overwrite a DECISION (K1-b: acknowledged/assigned/needs_ // verification/mitigated/fix_applied) with "cannot verify // either way". "Cannot tell" is not new information about a // case an operator already acted on — only the no-decision // default ('open') moves to 'unverified'; every other // non-terminal status is left exactly where the operator (or // an earlier recheck) put it. 'unverified' itself is the // idempotent steady state once reached — status stays // 'unverified' on every later call for the same reason 'open' // is the only status this branch will ever move FROM. // 2. Reach a case THIS SAME sync call just opened. A freshly // created case's own evidence is, by construction, the // freshest thing sync read this call — a login_series // case's lockout window cannot possibly have elapsed one // instant after its own evidence was recorded, and that is a // fact about timing, not a reason to call the case // "unverified" before an operator has ever seen it as 'open'. // 3. Move a case to 'unverified' just because a login_series // window has not finished running yet (§6.4 review round 2, // m3-r2). Point 2 above only protects the ONE sync call // that created the case — the SECOND render, one instant // later, is not $just_created any more, yet the window is // exactly as far from elapsed as it was a moment ago. That is // still a fact about time, not a coverage gap the way a // disabled login log or a stale feed is — recheck_login_ // series marks it with 'pending' precisely so this branch // can tell the two apart and leave 'open' alone until the // window itself has actually run out. if ( (string) $case['status'] === 'open' && ! $just_created && empty( $verdict['pending'] ) ) { $repo->update_status( $case_id, 'unverified', $verdict['reason'] ); } $repo->record_recheck_event( $case_id, 'Recheck unavailable: ' . $verdict['reason'], [ 'result' => $result, 'reason' => $verdict['reason'] ] ); return $verdict; } // still_affected — record the attempt, change nothing: the case // stays wherever an operator's own K1-b decision (or its default // 'open') already put it. $repo->record_recheck_event( $case_id, 'Recheck: ' . $verdict['reason'], [ 'result' => $result, 'reason' => $verdict['reason'] ] ); return $verdict; } /** * @param array $case * @return array{result:string,reason:string}|null */ public static function verdict( array $case ) { switch ( (string) ( $case['type'] ?? '' ) ) { case 'file_change': return self::verdict_file_change( $case ); case 'vuln_match': return self::verdict_vuln_match( $case ); case 'login_series': return self::verdict_login_series( $case ); } return null; } // ─────────────────────── file_change ────────────────────────────────── private static function verdict_file_change( array $case ) { $evidence = self::latest_evidence( (int) $case['id'], 'integrity_scan' ); if ( $evidence === null ) { return [ 'result' => GUARDFORGE_Case_Model::RECHECK_UNAVAILABLE, 'reason' => 'No integrity-scan evidence recorded for this case.' ]; } $path = (string) ( $evidence['payload']['path'] ?? '' ); $scope = (string) ( $evidence['payload']['scope'] ?? 'core' ); $summary = class_exists( 'GUARDFORGE_Integrity' ) ? (array) get_option( GUARDFORGE_Integrity::LAST_RESULT_OPTION, [] ) : []; $scanned_at = (string) ( $summary['scanned_at'] ?? '' ); // A scan that has not moved on since this evidence was recorded is // the "skipped/failed scan" the plan names by name — not new // information to recheck against. $scan_happened = $scanned_at !== '' && strtotime( $scanned_at ) >= strtotime( (string) $evidence['observed_at'] ); $slug = $scope === 'plugin' ? strstr( $path . '/', '/', true ) : ''; $queued = array_map( 'strval', (array) ( $summary['queued'] ?? [] ) ); $reference_queued = $scope === 'core' ? in_array( 'core', $queued, true ) : ( $slug !== '' && in_array( $slug, $queued, true ) ); $still_flagged = false; foreach ( (array) ( $summary['flagged'] ?? [] ) as $row ) { if ( (string) ( $row['path'] ?? '' ) === $path && in_array( (string) ( $row['class'] ?? '' ), [ 'modified', 'unexpected' ], true ) ) { $still_flagged = true; break; } } // The stored summary's flagged list is capped (GUARDFORGE_Integrity:: // MAX_SUMMARY_PATHS) — a path that fell off the cap is unlisted, not // confirmed clean, so it is treated the same as a queued reference: // recheck cannot conclude anything from this run. $capped = ( (int) ( $summary['flagged_total'] ?? 0 ) ) > count( (array) ( $summary['flagged'] ?? [] ) ); // §6.4 K1-e: "no longer flagged" is only a fix if something actually // looked. Two ways a scan can flag nothing about this file while // having compared it against nothing at all: // * nobody publishes checksums for its package, ever (a theme, a // premium or custom plugin). Its case came from the snapshot diff // alone — payload class 'local_changed'/'local_added', written by // GUARDFORGE_Case_Model::candidates_from_local_diff — and its // file drops out of the very next scan's diff by construction (it // has become the snapshot it is compared to). Reading that as a // fix is exactly the disappearance this slice exists to prevent. // * the reference simply GONE since the run that flagged the file // (§6.4 K1-e review round 1, blocker 1). GUARDFORGE_Integrity_ // Reference::core/plugin return null on a transport failure // (DNS, timeout) and on any response code but 200/403/404/410, and // on a package pulled from wordpress.org — and NONE of those paths // adds the package to `queued`, which is fed only by the spent // fetch budget. classify_* then counts the files `unreferenced` // and flags nothing, so a single 502 from api.wordpress.org used // to close a case about a modified CORE file as fixed, with a // basis claiming a comparison that never ran. K1-c's own // acceptance line is "a failed/skipped scan does not give // 'verified'"; a scan that could not fetch the reference is one. // So the question is asked of every file_change case, whatever // flagged it originally: does the latest scan hold a published // reference for THIS file? `referenced` (GUARDFORGE_Integrity:: // classify) is the only field that can answer it — the reference // having been available on the run that opened a case says nothing // about the run that would close it. // Gated on the key being present, because a summary stored before // `referenced` existed carries none: reading its absence as "nothing // was referenced" would make every open case on every site read // "cannot tell" until the next scan had run. $package = self::package_of_evidence( $scope, $path, (array) $evidence['payload'] ); // wp-config.php — and anything else in CORE_LOCAL_FILES — is hashed // by the scan and deliberately NOT compared against the core package // (wordpress.org publishes no checksum for a file every site writes // itself), so `core` being referenced says nothing about it. This one // is never a comparison, whatever shape the summary has. $excluded_from_comparison = $scope === 'core' && class_exists( 'GUARDFORGE_Integrity' ) && in_array( $path, GUARDFORGE_Integrity::CORE_LOCAL_FILES, true ); $reference_absent = $excluded_from_comparison || ( array_key_exists( 'referenced', $summary ) && ( $package === '' || ! in_array( $package, array_map( 'strval', (array) $summary['referenced'] ), true ) ) ); return GUARDFORGE_Case_Model::recheck_file_change( [ 'scan_happened' => $scan_happened, 'reference_queued' => $reference_queued || ( $capped && ! $still_flagged ), 'still_flagged' => $still_flagged, 'reference_absent' => $reference_absent, // Which of the two shapes above this is, so the recorded reason // says the true thing: "nobody publishes checksums this file // could be compared against" and "the reference this file WAS // judged against is not available now" are different facts. A // case built from the snapshot diff alone is the first by // construction, and so is a file the comparison excludes by // name — no later scan will settle either one. 'reference_never' => $excluded_from_comparison || strpos( (string) ( $evidence['payload']['class'] ?? '' ), 'local_' ) === 0, ] ); } /** * The package key a file_change case's evidence belongs to, in the same * shape GUARDFORGE_Integrity::classify reports in `referenced` * ('core', 'plugin/', 'theme/'). * * Local-diff evidence carries it outright. Reference-flagged findings do * not — and neither does any evidence written before §6.4 K1-e — so it * is derived from scope and path by the same recipe the queue check * above already uses to derive ITS slug: a '/' is appended to $path * before cutting on the first one, so a one-file plugin's bare filename * still yields a non-empty string. This is deliberately NOT the recipe * GUARDFORGE_Integrity::package_of uses (that one returns '' for the * same input) — round 2 review, minor 1. * * @param string $scope 'core' | 'plugin' | 'theme'. * @param string $path Path relative to that scope's root. * @param array $payload The evidence's own payload. * @return string For a one-file plugin (no '/' in $path) this is * "plugin/" — a key * classify_plugins never adds to `referenced` (it only * ever adds a directory slug), so reference_absent still * comes out true for it, which is all the caller needs. */ private static function package_of_evidence( $scope, $path, array $payload ) { $package = (string) ( $payload['package'] ?? '' ); if ( $package !== '' ) { return $package; } if ( $scope === 'core' ) { return 'core'; } $dir = strstr( (string) $path . '/', '/', true ); return ( $dir === false || $dir === '' ) ? '' : $scope . '/' . $dir; } // ─────────────────────── vuln_match ─────────────────────────────────── private static function verdict_vuln_match( array $case ) { $evidence = self::latest_evidence( (int) $case['id'], 'vuln_feed' ); if ( $evidence === null ) { return [ 'result' => GUARDFORGE_Case_Model::RECHECK_UNAVAILABLE, 'reason' => 'No vulnerability-feed evidence recorded for this case.' ]; } $payload = (array) $evidence['payload']; $slug = (string) ( $payload['slug'] ?? '' ); $type = (string) ( $payload['type'] ?? 'plugin' ); $range = (string) ( $payload['affected_range'] ?? '' ); if ( class_exists( 'GUARDFORGE_Vuln_Feed' ) && GUARDFORGE_Vuln_Feed::pro_feed_active() && class_exists( 'GUARDFORGE_Vuln_Db' ) ) { $state = GUARDFORGE_Vuln_Db::state(); $installed = GUARDFORGE_Vuln_Db::instance()->installed_inventory(); } elseif ( class_exists( 'GUARDFORGE_Vuln_Feed' ) ) { $state = GUARDFORGE_Vuln_Feed::state(); $installed = GUARDFORGE_Vuln_Feed::instance()->installed_inventory(); } else { return [ 'result' => GUARDFORGE_Case_Model::RECHECK_UNAVAILABLE, 'reason' => 'No vulnerability feed is available on this site.' ]; } $version = $type === 'core' ? (string) ( $installed['core'] ?? '' ) : (string) ( $installed[ $type ][ $slug ] ?? '' ); $version_known = $version !== ''; $still_matches = $version_known && class_exists( 'GUARDFORGE_Vuln_Range' ) && GUARDFORGE_Vuln_Range::matches( $range, $version ); return GUARDFORGE_Case_Model::recheck_vuln_match( [ 'feed_status' => (string) ( $state['status'] ?? 'never_fetched' ), 'version_known' => $version_known, 'still_matches' => $still_matches, ] ); } // ─────────────────────── login_series ───────────────────────────────── private static function verdict_login_series( array $case ) { $evidence = self::latest_evidence( (int) $case['id'], 'login_log' ); if ( $evidence === null ) { return [ 'result' => GUARDFORGE_Case_Model::RECHECK_UNAVAILABLE, 'reason' => 'No login-log evidence recorded for this case.' ]; } $ip = (string) ( $evidence['payload']['ip'] ?? '' ); $username = (string) ( $evidence['payload']['username'] ?? '' ); $log_enabled = class_exists( 'GUARDFORGE_Options' ) && GUARDFORGE_Options::get( 'bf_log_enabled' ); $minutes = max( 1, (int) GUARDFORGE_Options::get( 'bf_lockout_minutes' ) ); $window = $minutes * MINUTE_IN_SECONDS; $last_ts = strtotime( (string) $evidence['observed_at'] ) ?: 0; $window_elapsed = $last_ts > 0 && ( time() - $last_ts ) >= $window; $new_failed_count = 0; if ( $log_enabled && $ip !== '' && $username !== '' ) { global $wpdb; $table = $wpdb->prefix . 'guardforge_login_log'; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- plugin-owned table ($wpdb->prefix + a literal) that no WordPress API reads; every value is a prepare placeholder or a fragment prepared on its own. $new_failed_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE ip = %s AND username = %s AND result IN ('failed','locked','soft_locked') AND event_time > %s", $ip, $username, (string) $evidence['observed_at'] ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared } return GUARDFORGE_Case_Model::recheck_login_series( [ 'log_enabled' => (bool) $log_enabled, 'window_elapsed' => $window_elapsed, 'new_failed_count' => $new_failed_count, 'control_active' => self::control_active( $ip ), ] ); } /** * Any ONE existing control counts — this recheck does not require every * mechanism at once, only that the site is not simply hoping the * attacker stays away: the IP's own bruteforce block is still in * effect, site-wide lockdown is on, or 2FA is required for admins AND * something on this site actually enforces it. * * `require_2fa_for_admins` and `lockdown_active` are both stored * PREFERENCES, not enforcement — the code that actually acts on either * one (class-two-factor-policy.php / class-lockdown.php) ships in Pro. * On a Free site, or one where Pro has since been deactivated, either * option can be sitting at its "on" value with nothing reading it at * all. Counting a preference nobody enforces as a control would let a * recheck call a login_series case 'fixed' behind a switch nobody is * holding — the same shape of defect for both options, so both get the * same class_exists gate. * * The bruteforce-transient branch mirrors the exact three gates * GUARDFORGE_Bruteforce::maybe_block_login itself checks before it * will actually turn the transient into a 429 — protection switched on, * IP not whitelisted, site not in staging mode — for the same reason: * a transient set while any of those three would let the request * through is not a control, it is a number sitting in the options table. */ private static function control_active( $ip ) { if ( class_exists( 'GUARDFORGE_Options' ) ) { if ( GUARDFORGE_Options::get( 'require_2fa_for_admins' ) && class_exists( 'GUARDFORGE_Two_Factor_Policy' ) ) { return true; } if ( GUARDFORGE_Options::get( 'lockdown_active' ) && class_exists( 'GUARDFORGE_Lockdown' ) ) { return true; } } if ( $ip !== '' && class_exists( 'GUARDFORGE_Bruteforce' ) && function_exists( 'get_transient' ) && GUARDFORGE_Options::get( 'brute_force_protection' ) && ! GUARDFORGE_Bruteforce::instance()->is_whitelisted( $ip ) && ! ( class_exists( 'GUARDFORGE_Staging' ) && GUARDFORGE_Staging::is_active() ) ) { $threshold = (int) GUARDFORGE_Options::get( 'bf_max_attempts' ); if ( (int) get_transient( GUARDFORGE_Bruteforce::attempts_key( $ip ) ) >= max( 1, $threshold ) ) { return true; } } return false; } // ─────────────────────── plumbing ───────────────────────────────────── /** The most recent case_event from $source, payload decoded — same helper shape as GUARDFORGE_Case_Actions::latest_evidence_payload, kept separate because this one returns the whole row (observed_at is needed here, not only the payload). */ private static function latest_evidence( $case_id, $source ) { $events = GUARDFORGE_Cases::instance()->get_case_events( $case_id ); $match = null; foreach ( $events as $event ) { if ( (string) ( $event['source'] ?? '' ) === $source ) { $match = $event; // rows are oldest-first; keep the last match. } } return $match; } }