from-status => allowed to-statuses. */ public static function allowed_transitions() { return [ 'open' => [ 'acknowledged', 'assigned', 'needs_verification', 'mitigated', 'fix_applied', 'explained' ], 'acknowledged' => [ 'assigned', 'needs_verification', 'mitigated', 'fix_applied', 'explained' ], 'assigned' => [ 'acknowledged', 'needs_verification', 'mitigated', 'fix_applied', 'explained' ], 'needs_verification' => [ 'acknowledged', 'assigned', 'mitigated', 'fix_applied', 'explained', 'open' ], 'mitigated' => [ 'needs_verification', 'fixed', 'unverified', 'fix_applied' ], 'fix_applied' => [ 'needs_verification', 'fixed', 'unverified' ], 'unverified' => [ 'needs_verification', 'mitigated', 'fix_applied', 'fixed', 'explained' ], // Terminal: nothing here means neither status accepts a K1-b // decision. A closed case that recurs gets a NEW case row // (GUARDFORGE_Cases::find_or_create), not a reopened old one. 'explained' => [], 'fixed' => [], ]; } /** @return bool Whether $to is a decision this case may move to from $from. */ public static function can_transition( $from, $to ) { $matrix = self::allowed_transitions(); return in_array( (string) $to, $matrix[ (string) $from ] ?? [], true ); } // ─────────────────────── identity ─────────────────────────────────── /** * Deterministic identity of an investigation. Order-independent — * ksort first — so callers do not have to remember the exact field * order used the first time this case_key was computed. * * @param string $type One of self::TYPES. * @param array $subject The natural key of the thing being * investigated (e.g. ['scope'=>'plugin','path'=>'x/y.php']). * @return string sha256 hex digest, 64 characters. */ public static function case_key( $type, array $subject ) { ksort( $subject ); return hash( 'sha256', (string) $type . '|' . self::canonical( $subject ) ); } /** * Content address of one observation, scoped to a case's own evidence * timeline — a DELIBERATELY separate canonicalisation from * GUARDFORGE_Event_Explainer::event_key, not "the same recipe" as an * earlier version of this comment claimed (§6.4 review round 1, M2): * this ksorts before hashing (so a future reordering of a candidate's * fields cannot silently orphan every evidence row already stored under * the old field order) and Explainer's does not; the two also hash * different SHAPES for the same underlying observation on purpose — a * case's file_change evidence carries {scan_id,path,class,scope} because * that is what a case's own dedup/recheck logic needs, while Explainer's * integrity shape carries {change,file,hash,scope} because that is what * makes a paid AI explanation cacheable. A login_log row is the one * place the two systems' inputs actually line up field-for-field, and * even there this hashes {kind,id} only (the row id already determines * every other column) while Explainer hashes the row's own event_time/ * ip/username/result/user_agent for its own, unrelated pricing reason. * Unifying the two into one cross-screen id would mean inventing data * this method does not have (a content hash for a scan's flagged list, * which the integrity summary never stores) or coupling this file to * WordPress functions it deliberately has none of — see the class * docblock. What IS true, and load-bearing, stays true: the SAME * observation computed twice by THIS method (case re-opened by sync, * case re-read on a later page view) always yields the same key, which * is what add_evidence's lookup-before-insert and * GUARDFORGE_Cases::all_evidence_already_known both depend on. * * @param array $observation * @return string */ public static function evidence_key( array $observation ) { ksort( $observation ); return hash( 'sha256', self::canonical( $observation ) ); } /** json_encode with sorted keys, so PHP's key order never changes the hash. */ private static function canonical( array $data ) { if ( function_exists( 'wp_json_encode' ) ) { $encoded = wp_json_encode( $data ); return is_string( $encoded ) ? $encoded : ''; } return (string) json_encode( $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure unit context, wp_json_encode preferred above. } // ─────────────────────── file_change ───────────────────────────────── /** * One candidate case per flagged file from the last integrity scan. * * §6.4 K1-e: $context['baselines'] (built by GUARDFORGE_Cases:: * sync_file_changes, keyed "scope|path") is the operator's own stored * approval of a file's exact state (GUARDFORGE_Trusted_Baseline) — the * hash and package version they signed off when they closed an earlier * case as "expected change" (class-case-actions.php's * handle_expected_change). A flagged row whose CURRENT hash still * matches that approval is not new information — it is the same reviewed * bytes read again — so no candidate is built for it at all: the earlier * 'explained' case is left exactly where it is, undisturbed. * * The approval is matched on the HASH ALONE, and the stored package * version is deliberately NOT part of that test. An approval is an * operator's statement about a file's contents, and a routine update of * the package it lives in does not change those contents when the * release did not touch that file — which is the overwhelmingly common * case, since most files are identical between two adjacent releases. * Making the version part of the match would therefore reopen a fresh * "suspicious change" case for an approved, byte-identical file every * time an unrelated update bumped the version: a change out of a routine * update reported as a finding, which is precisely what this slice * exists to stop. What the version bump DOES mean — that an approved * customisation now sits against a newer published package — is not * hidden: the file stays listed as `modified` on the File integrity * screen for exactly as long as it differs from the published package, * with no case model in the way. * * A DIFFERING hash means the approval no longer describes what is on * disk, so this falls through to a genuine candidate, with that fact — * and, when the package also moved, which version it moved to — named in * its own confidence_reason rather than silently dropped; * find_or_create_case then opens a fresh case for the recurrence, the * same way any other new evidence against a terminal case_key already * does (see that method's own docblock). * * @param array $flagged * @param array{scan_id:int,scanned_at:string,queued:string,baselines?:array>} $context * @return array Candidate cases — case_key, subject, evidence, confidence, coverage. */ public static function candidates_from_integrity( array $flagged, array $context ) { $scan_id = (int) ( $context['scan_id'] ?? 0 ); $scanned_at = (string) ( $context['scanned_at'] ?? '' ); $queued = array_map( 'strval', (array) ( $context['queued'] ?? [] ) ); $baselines = (array) ( $context['baselines'] ?? [] ); $out = []; foreach ( $flagged as $row ) { $class = (string) ( $row['class'] ?? '' ); if ( ! in_array( $class, [ 'modified', 'unexpected' ], true ) ) { continue; // 'missing'/'official'/'unreferenced' are not this case type. } $path = (string) ( $row['path'] ?? '' ); $scope = (string) ( $row['scope'] ?? 'core' ); if ( $path === '' ) { continue; } $hash = (string) ( $row['hash'] ?? '' ); $version = (string) ( $row['version'] ?? '' ); $baseline = $baselines[ $scope . '|' . $path ] ?? null; $baseline_note = ''; if ( is_array( $baseline ) ) { if ( $hash !== '' && (string) ( $baseline['file_hash'] ?? '' ) === $hash ) { continue; // Still byte-for-byte the approved file — not a case. } $baseline_note = self::trusted_baseline_stale_note( $baseline, $version ); } $slug = $scope === 'plugin' ? strstr( $path . '/', '/', true ) : ''; $reference_queued = $scope === 'core' ? in_array( 'core', $queued, true ) : ( $slug !== '' && in_array( $slug, $queued, true ) ); $confidence = $reference_queued ? 'medium' : 'high'; $reason = $reference_queued ? 'The wordpress.org reference for this ' . ( $scope === 'core' ? 'core version' : "plugin ('" . $slug . "')" ) . ' was queued for the next scan (fetch budget spent) — this run compared against the previous snapshot only.' : 'Compared against the published wordpress.org checksums for the version installed at scan time.'; if ( $baseline_note !== '' ) { $reason = $baseline_note . ' ' . $reason; } $out[] = [ 'case_key' => self::case_key( 'file_change', [ 'scope' => $scope, 'path' => $path ] ), 'subject' => $path, 'confidence' => $confidence, 'confidence_reason' => $reason, 'coverage' => self::integrity_coverage( $context ), 'evidence' => [ [ 'event_key' => self::evidence_key( [ 'kind' => 'integrity', 'scan_id' => $scan_id, 'path' => $path, 'class' => $class ] ), 'observed_at' => $scanned_at, 'source' => 'integrity_scan', 'source_ref' => (string) $scan_id, 'summary' => sprintf( '%s: %s (scope: %s)', ucfirst( $class ), $path, $scope ), // hash/version (§6.4 K1-e): so GUARDFORGE_Case_Actions:: // handle_expected_change can record a trusted baseline // straight from this evidence, with no second disk read. 'payload' => [ 'path' => $path, 'class' => $class, 'scope' => $scope, 'scan_id' => $scan_id, 'hash' => $hash, 'version' => $version ], ] ], ]; } return $out; } /** * One honest sentence for a file whose contents no longer match the * approval on record. The file having been edited AGAIN is the fact in * both branches — a stored approval is only ever consulted on a hash * mismatch — and the package version having also moved is named as the * extra circumstance it is, never as the explanation: a routine update * would have left the shipped bytes behind, and these are not them. * * @param array $baseline The stored approval row. * @param string $version The CURRENT installed package version, '' if unknown. */ private static function trusted_baseline_stale_note( array $baseline, $version ) { $approved_version = (string) ( $baseline['package_version'] ?? '' ); $approved_at = (string) ( $baseline['approved_at'] ?? '' ); if ( $approved_version !== '' && $version !== '' && $approved_version !== $version ) { return sprintf( 'A previous change to this file was approved as expected on %s, for package version %s. Its contents differ from what was approved, and the installed version is now %s — so neither that approval nor the update to %s describes what is on disk, and this is treated as a new finding.', $approved_at, $approved_version, $version, $version ); } return sprintf( 'A previous change to this file was approved as expected on %s. The file has changed again since then, at the same package version, so that approval no longer applies and this is treated as a new finding.', $approved_at ); } // ─────────────────── the local diff (§6.4 K1-e) ─────────────────────── /** A locally-changed file whose current bytes ARE the published package's. */ const LOCAL_PACKAGE_FILE = 'package_file'; /** A locally-changed file whose current bytes an operator has approved. */ const LOCAL_APPROVED = 'approved'; /** Changed while its package's version moved, with no published package to check that against. */ const LOCAL_PACKAGE_UPDATE = 'package_update'; /** The published package contradicts the file — candidates_from_integrity already owns this one. */ const LOCAL_REFERENCE_FLAGGED = 'reference_flagged'; /** Nothing on record explains this change. */ const LOCAL_UNEXPLAINED = 'unexplained'; public static function explain_local_changes( array $local_changes, array $context ) { $baselines = (array) ( $context['baselines'] ?? [] ); $flagged_paths = (array) ( $context['flagged_paths'] ?? [] ); $out = []; foreach ( $local_changes as $row ) { $path = (string) ( $row['path'] ?? '' ); if ( $path === '' ) { continue; } $scope = (string) ( $row['scope'] ?? 'core' ); $package = (string) ( $row['package'] ?? '' ); $verdict = (string) ( $row['verdict'] ?? 'unreferenced' ); $hash = (string) ( $row['hash'] ?? '' ); $version = (string) ( $row['version'] ?? '' ); $before = (string) ( $row['previous_version'] ?? '' ); $moved = $version !== '' && $before !== '' && $version !== $before; $baseline = $baselines[ $scope . '|' . $path ] ?? null; // §6.4 K1-e review round 2, blocker 1: a path named in // GUARDFORGE_Integrity::CORE_LOCAL_FILES (wp-config.php) is // hashed by the scan but excluded from the reference comparison // BY NAME, whatever this run's `verdict` for it says — the same // check GUARDFORGE_Case_Recheck::verdict_file_change makes // before it will call a case closed. That is a different fact // from "no reference was obtained for this package on this // run": wordpress.org DOES publish checksums for the package // this file's `verdict` came back `unreferenced` for, it simply // never judges this one file. The row carries the answer for // whoever reads it after this method, so the fact is worked out // once. $row['excluded_from_comparison'] = $scope === 'core' && class_exists( 'GUARDFORGE_Integrity' ) && in_array( $path, GUARDFORGE_Integrity::CORE_LOCAL_FILES, true ); // Settled before every explanation below, `official` included: a // stored summary whose two lists ever disagreed about a path must // not let it be explained here AND flagged there. The flagged // list is the authoritative one — it is what // candidates_from_integrity builds this path's case from. if ( ! empty( $flagged_paths[ $scope . '|' . $path ] ) || in_array( $verdict, [ 'modified', 'unexpected' ], true ) ) { $row['status'] = self::LOCAL_REFERENCE_FLAGGED; $row['reason'] = sprintf( 'The package wordpress.org publishes for the installed version of %s does not contain this file\'s current contents.', self::package_label( $package, $scope ) ); $out[] = $row; continue; } if ( $verdict === 'official' ) { $row['status'] = self::LOCAL_PACKAGE_FILE; $row['reason'] = $moved ? sprintf( '%s was updated from %s to %s since the previous scan, and this file now matches the package wordpress.org publishes for %s — a change out of a routine update.', self::package_label( $package, $scope ), $before, $version, $version ) : sprintf( 'This file matches the package wordpress.org publishes for the installed version of %s (%s).', self::package_label( $package, $scope ), $version ); } elseif ( is_array( $baseline ) && $hash !== '' && (string) ( $baseline['file_hash'] ?? '' ) === $hash ) { $row['status'] = self::LOCAL_APPROVED; $row['reason'] = sprintf( 'These are the contents an operator approved as an expected change on %s.', (string) ( $baseline['approved_at'] ?? '' ) ); } elseif ( $moved && $verdict === 'unreferenced' ) { $row['status'] = self::LOCAL_PACKAGE_UPDATE; $row['reason'] = sprintf( '%s was updated from %s to %s since the previous scan, which is consistent with this change. wordpress.org publishes no checksums for it, so the file itself was not compared against a published package.', self::package_label( $package, $scope ), $before, $version ); } else { $row['status'] = self::LOCAL_UNEXPLAINED; $row['reason'] = self::unexplained_local_reason( $package, $scope, $version, $before, $row['excluded_from_comparison'] ); } $out[] = $row; } return $out; } /** * Why nothing on record explains this change — naming the missing piece * rather than rounding it down to a generic "unknown", so the operator * can tell "there is nothing to compare this against, ever" apart from * "there was something to compare it against and the version sat still". * * §6.4 K1-e review round 2, blocker 1: "nothing to compare this against, * ever" is itself two different facts, and only one of them is "nobody * publishes checksums for the package". The other is a path excluded * from the comparison BY NAME while the package it lives in — core — * is published and was in fact obtained this run (wp-config.php, * GUARDFORGE_Integrity::CORE_LOCAL_FILES): saying wordpress.org * publishes no checksums for WordPress core, on the very scan that got * them, is false, not weak. $excluded_from_comparison picks the true * sentence for that path; every other case keeps the package-shaped one. * * @param bool $excluded_from_comparison True for a path the comparison * excludes by name (see explain_local_changes) — never * true together with $scope other than 'core', since * GUARDFORGE_Integrity::CORE_LOCAL_FILES holds only core * paths. */ private static function unexplained_local_reason( $package, $scope, $version, $before, $excluded_from_comparison = false ) { $label = self::package_label( $package, $scope ); if ( $excluded_from_comparison ) { return sprintf( 'Nothing explains this change: this file is excluded from the %s comparison by name — every site writes it for itself, so no scan will ever check it against a published package.', $label ); } if ( $version === '' ) { return sprintf( 'The installed version of %s could not be determined, so nothing on record explains this change.', $label ); } // Reaching here as 'core' (and not excluded by name) means // classify_core itself came back without a published reference on // this run — a fact a later scan may still resolve, unlike the // excluded file above, which no scan ever will. "wordpress.org // publishes no checksums for WordPress core" would be false here // too: core checksums exist, this run simply did not have them. $no_reference = $scope === 'core' ? sprintf( 'this scan held no published checksums for %s to compare the file against', $label ) : 'wordpress.org publishes no checksums for it to compare the file against'; if ( $before === '' ) { return sprintf( 'This is the first scan that recorded a version for %s, so there is no earlier version to tell a routine update apart from an edit — and %s.', $label, $no_reference ); } return sprintf( 'Nothing explains this change: %s is still on version %s, and %s.', $label, $version, $no_reference ); } /** 'core', or the slug out of a 'plugin/' / 'theme/' package key. */ private static function package_label( $package, $scope ) { $package = (string) $package; if ( $package === 'core' ) { return 'WordPress core'; } $cut = strpos( $package, '/' ); $slug = $cut === false ? $package : substr( $package, $cut + 1 ); if ( $slug === '' ) { return (string) $scope; } return sprintf( "the %s '%s'", (string) $scope, $slug ); } public static function candidates_from_local_diff( array $local_changes, array $context ) { $scan_id = (int) ( $context['scan_id'] ?? 0 ); $scanned_at = (string) ( $context['scanned_at'] ?? '' ); $out = []; foreach ( self::explain_local_changes( $local_changes, $context ) as $row ) { if ( (string) $row['status'] !== self::LOCAL_UNEXPLAINED ) { continue; } $path = (string) $row['path']; $scope = (string) ( $row['scope'] ?? 'core' ); $change = (string) ( $row['change'] ?? 'changed' ); // §6.4 K1-e review round 2, blocker 1: same fact split as // unexplained_local_reason — a path the comparison excludes // by name (wp-config.php) reads differently from a package // nobody publishes checksums for, even though both leave this // file's "unchanged" unverified. $coverage = self::integrity_coverage( $context ); $coverage[] = ! empty( $row['excluded_from_comparison'] ) ? sprintf( 'This file was compared against the previous snapshot only — it is excluded from the %s comparison by name, so "unchanged" is the strongest thing a future scan can say about it.', self::package_label( (string) ( $row['package'] ?? '' ), $scope ) ) : sprintf( 'This file was compared against the previous snapshot only — wordpress.org publishes no checksums for %s, so "unchanged" is the strongest thing a future scan can say about it.', self::package_label( (string) ( $row['package'] ?? '' ), $scope ) ); $out[] = [ 'case_key' => self::case_key( 'file_change', [ 'scope' => $scope, 'path' => $path ] ), 'subject' => $path, // Never 'high': nothing was checked against a published // package, which is the only thing that earns 'high' for a // file_change case (see candidates_from_integrity). 'confidence' => 'low', 'confidence_reason' => (string) $row['reason'], 'coverage' => $coverage, 'evidence' => [ [ 'event_key' => self::evidence_key( [ 'kind' => 'local_diff', 'scan_id' => $scan_id, 'path' => $path, 'change' => $change ] ), 'observed_at' => $scanned_at, 'source' => 'integrity_scan', 'source_ref' => (string) $scan_id, 'summary' => sprintf( '%s since the previous snapshot: %s (scope: %s)', $change === 'added' ? 'Added' : 'Changed', $path, $scope ), // class 'local_changed'/'local_added' rather than one of // GUARDFORGE_Integrity::CLASSES on purpose: this // observation is NOT a verdict from the published // reference, and GUARDFORGE_Case_Recheck reads exactly // this field to know it must not conclude "no longer // flagged, therefore fixed" from a comparison that never // happened. 'payload' => [ 'path' => $path, 'class' => 'local_' . $change, 'scope' => $scope, 'package' => (string) ( $row['package'] ?? '' ), 'scan_id' => $scan_id, 'hash' => (string) ( $row['hash'] ?? '' ), 'version' => (string) ( $row['version'] ?? '' ), ], ] ], ]; } return $out; } /** @return string What this scan run did NOT establish. */ private static function integrity_coverage( array $context ) { $notes = []; $queued = (array) ( $context['queued'] ?? [] ); if ( $queued ) { $notes[] = sprintf( 'Reference lookup was queued (not fetched this run) for: %s.', implode( ', ', array_map( 'strval', $queued ) ) ); } if ( ! empty( $context['scope_extras_off'] ) ) { $notes[] = 'Scan scope is core files only — plugins and themes are not included.'; } return $notes; } // ─────────────────────── vuln_match ────────────────────────────────── /** * One candidate case per advisory match (find_matches output). * * @param array> $hits From GUARDFORGE_Vuln_Feed::find_matches or the Pro equivalent. * @param array{status:string,fetched_at:string,tier:string} $feed Feed state — status is the four-value contract both tiers share. * @return array */ public static function candidates_from_vuln_matches( array $hits, array $feed ) { $status = (string) ( $feed['status'] ?? 'never_fetched' ); $confidence = 'medium'; $reason = 'The advisory mirror was fetched successfully, but not within its freshness window.'; if ( $status === 'clean' ) { $confidence = 'high'; $reason = 'Matched against the most recent successful fetch of the advisory feed.'; } elseif ( in_array( $status, [ 'failed', 'never_fetched' ], true ) ) { $confidence = 'low'; $reason = $status === 'never_fetched' ? 'No advisory fetch has ever completed — this match is against stored data from before this fetch history began, if any.' : 'The last fetch attempt failed; this match is against whatever was stored from an earlier, unconfirmed-fresh pull.'; } $coverage = []; if ( ! empty( $feed['tier'] ) && $feed['tier'] === 'free' ) { $coverage[] = 'Free feed: advisories are published with a delay (see the Vulnerabilities screen for the current number of days).'; } if ( $status !== 'clean' ) { $coverage[] = sprintf( 'Feed state at match time: %s.', $status ); } $out = []; foreach ( $hits as $hit ) { $slug = (string) ( $hit['slug'] ?? '' ); $cve = (string) ( $hit['cve'] ?? '' ); if ( $slug === '' ) { continue; } // CVE + plugin when there is a CVE; otherwise slug + range, so two // advisories without a CVE for the same plugin still get separate // cases rather than colliding on an empty key. $identity = $cve !== '' ? [ 'slug' => $slug, 'cve' => $cve ] : [ 'slug' => $slug, 'range' => (string) ( $hit['affected_range'] ?? '' ) ]; $out[] = [ 'case_key' => self::case_key( 'vuln_match', $identity ), 'subject' => $slug . ( $cve !== '' ? ' (' . $cve . ')' : '' ), 'confidence' => $confidence, 'confidence_reason' => $reason, 'coverage' => $coverage, 'evidence' => [ [ 'event_key' => self::evidence_key( [ 'kind' => 'vuln', 'slug' => $slug, 'cve' => $cve, 'range' => (string) ( $hit['affected_range'] ?? '' ), 'version' => (string) ( $hit['version'] ?? '' ) ] ), 'observed_at' => (string) ( $feed['fetched_at'] ?? '' ), 'source' => 'vuln_feed', 'source_ref' => (string) ( $feed['fetched_at'] ?? '' ), 'summary' => sprintf( '%s %s is inside advisory range %s%s', $slug, (string) ( $hit['version'] ?? '' ), (string) ( $hit['affected_range'] ?? '' ), $cve !== '' ? ' (' . $cve . ')' : '' ), 'payload' => $hit, ] ], ]; } return $out; } // ─────────────────────── login_series ──────────────────────────────── /** * Group login_log rows by (ip, username) and flag any group whose failed * count within $window_seconds of the group's own first failed row meets * $threshold. A success row for that username inside the same window * raises confidence — see the class docblock for why success is * evidence that enriches the case rather than a gate on its existence: * a blocked series is already the pattern this case type watches for, * and it must not disappear from view merely because the attacker never * got in. * * Pure: $rows is already-fetched data (id, event_time, ip, username, * result), sorted or not — this function sorts by time itself. * * @param array $rows * @param int $threshold GUARDFORGE_Options::get('bf_max_attempts'). * @param int $window_seconds GUARDFORGE_Options::get('bf_lockout_minutes') * MINUTE_IN_SECONDS. * @return array */ public static function candidates_from_login_log( array $rows, $threshold, $window_seconds ) { $threshold = max( 1, (int) $threshold ); $window_seconds = max( 1, (int) $window_seconds ); // event_time is a second wide and a burst fires several attempts in one, // so ties are the normal case here, and what follows from this order is // stored: the evidence rows are inserted in it (the case screen reads them // by observed_at and then by their own id), the first success met is the // one named as evidence, and the candidates come out in the order their // first row appeared. Ties go in the order the log wrote them (id // ascending), the same pair every read of the evidence uses. usort( $rows, function ( $a, $b ) { $by_time = strcmp( (string) ( $a['event_time'] ?? '' ), (string) ( $b['event_time'] ?? '' ) ); if ( 0 !== $by_time ) { return $by_time; } return (int) ( $a['id'] ?? 0 ) <=> (int) ( $b['id'] ?? 0 ); } ); $groups = []; foreach ( $rows as $row ) { $ip = (string) ( $row['ip'] ?? '' ); $username = (string) ( $row['username'] ?? '' ); $result = (string) ( $row['result'] ?? '' ); if ( $ip === '' || $username === '' ) { continue; } $key = $ip . '|' . $username; if ( ! isset( $groups[ $key ] ) ) { $groups[ $key ] = [ 'ip' => $ip, 'username' => $username, 'failed' => [], 'success' => null ]; } if ( in_array( $result, [ 'failed', 'locked', 'soft_locked' ], true ) ) { $groups[ $key ]['failed'][] = $row; } elseif ( $result === 'success' && $groups[ $key ]['success'] === null ) { $groups[ $key ]['success'] = $row; } } $out = []; foreach ( $groups as $group ) { $failed = $group['failed']; if ( count( $failed ) < $threshold ) { continue; } $first_ts = strtotime( (string) ( $failed[0]['event_time'] ?? '' ) ) ?: 0; $last_ts = strtotime( (string) ( $failed[ count( $failed ) - 1 ]['event_time'] ?? '' ) ) ?: 0; if ( $first_ts === 0 || ( $last_ts - $first_ts ) > $window_seconds ) { // The failures are real but span longer than the configured // window — not the burst this case type is built to catch. // (A future slice may add a slower-burn pattern; today this // is a coverage gap, not a false negative to hide.) continue; } $success = $group['success']; $success_after = false; if ( $success !== null ) { $success_ts = strtotime( (string) ( $success['event_time'] ?? '' ) ) ?: 0; $success_after = $success_ts >= $first_ts && ( $success_ts - $first_ts ) <= $window_seconds; } $confidence = $success_after ? 'high' : 'medium'; $reason = $success_after ? sprintf( 'A successful login for %s followed %d failed attempts from %s within the lockout window.', $group['username'], count( $failed ), $group['ip'] ) : sprintf( '%d failed attempts from %s against %s crossed the configured threshold; no successful login has been observed for this username in the same window.', count( $failed ), $group['ip'], $group['username'] ); $evidence = []; foreach ( $failed as $row ) { $evidence[] = [ 'event_key' => self::evidence_key( [ 'kind' => 'login', 'id' => (int) ( $row['id'] ?? 0 ) ] ), 'observed_at' => (string) ( $row['event_time'] ?? '' ), 'source' => 'login_log', 'source_ref' => (string) ( $row['id'] ?? '' ), 'summary' => sprintf( 'Failed login for %s from %s', $group['username'], $group['ip'] ), 'payload' => $row, ]; } if ( $success_after && $success !== null ) { $evidence[] = [ 'event_key' => self::evidence_key( [ 'kind' => 'login', 'id' => (int) ( $success['id'] ?? 0 ) ] ), 'observed_at' => (string) ( $success['event_time'] ?? '' ), 'source' => 'login_log', 'source_ref' => (string) ( $success['id'] ?? '' ), 'summary' => sprintf( 'Successful login for %s from %s after %d failed attempts', $group['username'], $group['ip'], count( $failed ) ), 'payload' => $success, ]; } $out[] = [ 'case_key' => self::case_key( 'login_series', [ 'ip' => $group['ip'], 'username' => $group['username'] ] ), 'subject' => $group['username'] . ' @ ' . $group['ip'], 'confidence' => $confidence, 'confidence_reason' => $reason, 'coverage' => [ 'Grouping is by the exact (IP, username) pair only — the same username attacked from rotating IPs, or one IP spraying many usernames, is not correlated into this case.', ], 'evidence' => $evidence, ]; } return $out; } // ─────────────────── recheck (§6.4 K1-c) ────────────────────────────── /** * Every verdict a recheck may reach. 'still_affected' and 'unavailable' * both leave a case's status untouched — only 'fixed' closes anything, * and only GUARDFORGE_Case_Recheck (the WordPress-facing half, which * gathers the inputs these pure functions below take) ever writes it. * Deliberately NOT gated through allowed_transitions: that matrix * governs which DECISION (K1-b, an operator's own click) may move a * case, and none of them targets 'fixed'. A recheck is not a decision — * it is the system confirming a decision already made (or, for a * file_change case with no decision at all, confirming the file simply * matches again) — so its only gate is "not already terminal" * (GUARDFORGE_Cases::recheck_open_cases enforces that before either * function below is ever reached). */ const RECHECK_FIXED = 'fixed'; const RECHECK_STILL_OPEN = 'still_affected'; const RECHECK_UNAVAILABLE = 'unavailable'; /** * Type 1: did a genuinely NEW integrity scan run since this case's own * evidence, and if so, is the file still flagged against the published * reference? * * A scan that has not advanced since the case was last touched is * exactly the "skipped/failed scan" the plan calls out by name — the * monitor may be switched off, or nobody has run one since — and this * must say so rather than silently agreeing with whatever the case * already believes: an absence of new information is not evidence of a * fix. * * §6.4 K1-e adds `reference_absent`, and it is a different fact from * `reference_queued`: queued means wordpress.org has an answer and this * run could not afford to ask, so a later run will settle it, while * absent means the latest scan compared this file against nothing at * all. Both refuse to conclude anything, and they must: "the latest scan * no longer flags this file" is only a fix when there was a published * package doing the flagging. Without one, a case opened from the * snapshot diff alone (candidates_from_local_diff) would otherwise * close itself as `fixed` on the very next scan, purely because the * changed file has by then become the snapshot it is compared against — * the exact disappearance this slice exists to prevent — and a case * flagged by the reference would close on the night api.wordpress.org * answered 502 (review round 1, blocker 1). * * `reference_never` splits the ONE refusal into the two true sentences, * because they are different facts about the world: nobody publishes * checksums this file could be compared against — a theme, a premium or * custom plugin, or wp-config.php, which is inside a package that IS * published and is itself excluded from it (GUARDFORGE_Integrity:: * CORE_LOCAL_FILES) — so no scan will ever settle it; versus a reference * that did judge this file once and was not available this time, which a * later scan may well settle. * * @param array{scan_happened:bool,reference_queued:bool,reference_absent?:bool,reference_never?:bool,still_flagged:bool} $ctx * @return array{result:string,reason:string} */ public static function recheck_file_change( array $ctx ) { if ( empty( $ctx['scan_happened'] ) ) { return [ 'result' => self::RECHECK_UNAVAILABLE, 'reason' => 'No integrity scan has run since this case was opened — there is nothing new to recheck against.', ]; } if ( ! empty( $ctx['reference_queued'] ) ) { return [ 'result' => self::RECHECK_UNAVAILABLE, 'reason' => 'The published reference for this file was not available on the latest scan (queued, or the flagged list is capped) — comparison is not possible yet.', ]; } if ( ! empty( $ctx['still_flagged'] ) ) { return [ 'result' => self::RECHECK_STILL_OPEN, 'reason' => 'The latest integrity scan still classifies this file as modified or unexpected.', ]; } if ( ! empty( $ctx['reference_absent'] ) ) { if ( ! empty( $ctx['reference_never'] ) ) { return [ 'result' => self::RECHECK_UNAVAILABLE, 'reason' => 'wordpress.org publishes no checksums this file could be compared against, so the latest scan could only compare it against the previous snapshot — which cannot confirm the change was benign, however many scans go by.', ]; } return [ 'result' => self::RECHECK_UNAVAILABLE, 'reason' => 'Reference unavailable: the latest scan held no published package for this file, so nothing compared it against one — and that comparison is the only thing that could stop flagging it.', ]; } return [ 'result' => self::RECHECK_FIXED, 'reason' => 'The latest integrity scan no longer flags this file against the published reference.', ]; } /** * Type 2: the installed version against the advisory's own range, using * class-vuln-range.php — but only meaningful when the feed itself is * fresh ('clean', the same four-value contract GF-G3 gave both tiers). * A stale or failed feed cannot tell "the vendor fixed it" apart from * "we have not looked lately", so this refuses to call it either way. * * @param array{feed_status:string,version_known:bool,still_matches:bool} $ctx * @return array{result:string,reason:string} */ public static function recheck_vuln_match( array $ctx ) { $status = (string) ( $ctx['feed_status'] ?? 'never_fetched' ); if ( $status !== 'clean' ) { return [ 'result' => self::RECHECK_UNAVAILABLE, /* translators: %s is the feed's own state (never_fetched/failed/stale). */ 'reason' => sprintf( 'The advisory feed is not fresh right now (state: %s) — a version check against it would not be trustworthy.', $status ), ]; } if ( empty( $ctx['version_known'] ) ) { return [ 'result' => self::RECHECK_UNAVAILABLE, 'reason' => 'The installed version for this plugin/theme could not be determined on this recheck (it may have been removed) — a version comparison needs a version.', ]; } if ( ! empty( $ctx['still_matches'] ) ) { return [ 'result' => self::RECHECK_STILL_OPEN, 'reason' => 'The installed version is still inside the advisory\'s affected range.', ]; } return [ 'result' => self::RECHECK_FIXED, 'reason' => 'The installed version now falls outside the advisory\'s affected range, checked against the freshest successful feed fetch.', ]; } /** * Type 3: the window since this case's last evidence must have fully * elapsed with no new failed attempts for the SAME (ip, username), AND * some control must actually be in place. An absence of new attempts * with nothing currently blocking a new one is not evidence the risk * ended — only that nobody has tried again yet. * * @param array{log_enabled:bool,window_elapsed:bool,new_failed_count:int,control_active:bool} $ctx * @return array{result:string,reason:string} */ public static function recheck_login_series( array $ctx ) { if ( empty( $ctx['log_enabled'] ) ) { return [ 'result' => self::RECHECK_UNAVAILABLE, 'reason' => 'The login log is switched off, so new attempts against this IP/username cannot be observed.', ]; } if ( empty( $ctx['window_elapsed'] ) ) { return [ 'result' => self::RECHECK_UNAVAILABLE, 'reason' => 'Not enough time has passed since this case\'s last observation to confirm the window is clear.', // A fact about TIME, not a coverage gap (§6.4 review round 2, // m3-r2) — unlike the log-disabled branch above, this is not // "cannot verify", it is "cannot verify YET". GUARDFORGE_ // Case_Recheck::apply must not move a plain 'open' case to // 'unverified' for this reason alone, on ANY render, not only // the one that just created the case. 'pending' => true, ]; } if ( (int) ( $ctx['new_failed_count'] ?? 0 ) > 0 ) { return [ 'result' => self::RECHECK_STILL_OPEN, 'reason' => 'New failed attempts for the same IP/username were logged inside the recheck window.', ]; } if ( empty( $ctx['control_active'] ) ) { return [ 'result' => self::RECHECK_STILL_OPEN, 'reason' => 'No new failed attempts, but no control (IP block, lockdown or required 2FA) is currently active — an attacker who paused is not the same as one who is blocked.', ]; } return [ 'result' => self::RECHECK_FIXED, 'reason' => 'No new failed attempts for this IP/username inside the window, and a control is active.', ]; } }