authorize( 'guardforge_case_acknowledge' ); if ( ! $case ) { return; } $this->transition( $case, 'acknowledged', '', null, 'case_acknowledged', 'Acknowledged by an operator.' ); } public function handle_assign() { $case = $this->authorize( 'guardforge_case_assign' ); if ( ! $case ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in authorize above: manage_options and check_admin_referer( 'guardforge_case_assign' ). $user_id = isset( $_POST['assigned_user'] ) ? absint( wp_unslash( $_POST['assigned_user'] ) ) : 0; if ( $user_id < 1 ) { $this->redirect( (int) $case['id'], '', 'assign_no_user' ); return; } $this->transition( $case, 'assigned', '', $user_id, 'case_assigned', sprintf( 'Assigned to user #%d.', $user_id ) ); } /** * Type 1 only: an operator says the change is expected. Terminal — * evidence is kept, and the SAME integrity scan being re-read (e.g. the * Cases screen's own next render) does not reopen it. A LATER scan that * still finds this exact file unchanged at the same package version * also does not reopen it any more (§6.4 K1-e: this also records a * trusted baseline — see record_trusted_baseline below); only a * genuinely different finding — the file edited again, or the package * updated to a version this approval never covered — opens a fresh case * (§6.4 review round 2, m1-r2: the previous wording of this comment did * not say that, which read as "never reopens" — untrue then, and the * reason trusted baselines were left on the K1-e backlog rather than * solved here; solved now in GUARDFORGE_Case_Model:: * candidates_from_integrity). */ public function handle_expected_change() { $case = $this->authorize( 'guardforge_case_expected_change' ); if ( ! $case ) { return; } if ( (string) $case['type'] !== 'file_change' ) { $this->redirect( (int) $case['id'], '', 'wrong_case_type' ); return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in authorize above: manage_options and check_admin_referer( 'guardforge_case_expected_change' ). $reason = isset( $_POST['reason'] ) ? sanitize_textarea_field( wp_unslash( $_POST['reason'] ) ) : ''; if ( trim( $reason ) === '' ) { $this->redirect( (int) $case['id'], '', 'reason_required' ); return; } $this->transition( $case, 'explained', $reason, null, 'case_explained', $reason, function () use ( $case, $reason ) { $this->record_trusted_baseline( $case, $reason ); } ); } /** * §6.4 K1-e: an "expected change" decision also becomes a trusted * baseline — the file's exact hash and the package version it was * approved at, read straight from this case's own last integrity-scan * evidence (no disk re-read, no reference re-fetch; class-integrity.php * carries both on every 'modified'/'unexpected' finding as of this * slice). Without this, an approved custom file reopens a fresh case on * every future scan purely because its own hash still differs from the * published reference — that gap is exactly what this method closes. */ private function record_trusted_baseline( array $case, $reason ) { if ( ! class_exists( 'GUARDFORGE_Trusted_Baseline' ) ) { return; } $evidence = $this->latest_evidence_payload( (int) $case['id'], 'integrity_scan' ); if ( ! is_array( $evidence ) || empty( $evidence['path'] ) || empty( $evidence['hash'] ) ) { // Evidence recorded before this slice (no 'hash' key yet), or a // scan that never produced one for this path — nothing // trustworthy to store. The case still closes as explained; it // simply gets no baseline, same as before this slice existed. return; } GUARDFORGE_Trusted_Baseline::instance()->record( (string) ( $evidence['scope'] ?? 'core' ), (string) $evidence['path'], (string) $evidence['hash'], (string) ( $evidence['version'] ?? '' ), (int) $case['id'], (string) $reason ); } public function handle_needs_verification() { $case = $this->authorize( 'guardforge_case_needs_verification' ); if ( ! $case ) { return; } $this->transition( $case, 'needs_verification', '', null, 'case_needs_verification', 'Marked as needing verification.' ); } /** * "Apply available fix" — dispatches by (case type, chosen remedy). * Every branch either calls an existing mechanism (Pro's patch rule, * Pro's lockdown, the site's own brute-force blocklist) or refuses with * an honest reason; none of them invents a new enforcement path. */ public function handle_apply_fix() { $case = $this->authorize( 'guardforge_case_apply_fix' ); if ( ! $case ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in authorize above: manage_options and check_admin_referer( 'guardforge_case_apply_fix' ). $remedy = isset( $_POST['remedy'] ) ? sanitize_key( wp_unslash( $_POST['remedy'] ) ) : ''; if ( (string) $case['type'] === 'vuln_match' && $remedy === 'patch' ) { $this->apply_patch( $case ); return; } if ( (string) $case['type'] === 'login_series' && $remedy === 'block_ip' ) { $this->apply_block_ip( $case ); return; } if ( (string) $case['type'] === 'login_series' && $remedy === 'lockdown' ) { $this->apply_lockdown( $case ); return; } $this->redirect( (int) $case['id'], '', 'remedy_unavailable' ); } // ─────────────────────────── remedies ───────────────────────────────── /** * Type 2 — "Patch by update" through the existing Pro rule. Without Pro * on disk this refuses with the honest reason (no Pro installed), never * a numeric cap and never a licence check run from Free code: the only * gate here is class_exists, same as every other Free/Pro call site in * this plugin. */ private function apply_patch( array $case ) { if ( ! GUARDFORGE_Case_Model::can_transition( (string) $case['status'], 'fix_applied' ) ) { $this->redirect( (int) $case['id'], '', 'transition_not_allowed' ); return; } if ( ! class_exists( 'GUARDFORGE_Patch_Rule' ) ) { $this->redirect( (int) $case['id'], '', 'pro_not_installed' ); return; } $candidate = $this->latest_evidence_payload( (int) $case['id'], 'vuln_feed' ); if ( $candidate === null || empty( $candidate['slug'] ) ) { $this->redirect( (int) $case['id'], '', 'no_evidence' ); return; } $result = GUARDFORGE_Patch_Rule::instance()->apply_now( $candidate ); if ( empty( $result['ok'] ) ) { if ( class_exists( 'GUARDFORGE_Audit_Log' ) ) { GUARDFORGE_Audit_Log::instance()->record( 'case_apply_fix_failed', 'case', (string) $case['id'], (string) ( $result['message'] ?? '' ) ); } $this->redirect( (int) $case['id'], '', 'patch_failed' ); return; } $repo = GUARDFORGE_Cases::instance(); $repo->update_status( (int) $case['id'], 'fix_applied' ); $repo->record_decision_event( (int) $case['id'], sprintf( 'Patch by update: %s -> %s.', (string) ( $candidate['version'] ?? '?' ), (string) ( $result['to'] ?? '?' ) ), [ 'remedy' => 'patch', 'to' => $result['to'] ?? '' ] ); if ( class_exists( 'GUARDFORGE_Audit_Log' ) ) { GUARDFORGE_Audit_Log::instance()->record( 'case_fix_applied', 'case', (string) $case['id'], sprintf( '%s -> %s', (string) $candidate['slug'], (string) ( $result['to'] ?? '' ) ) ); } $this->redirect( (int) $case['id'], 'fix_applied' ); } /** * Type 3, "block IP" — the exact mechanism GUARDFORGE_Bruteforce already * enforces automatically (the same transient key, the same threshold and * window settings), just set immediately from an operator's decision * rather than waiting for the next failed attempt to cross it. * * Setting that transient is not itself the block — maybe_block_login * is, and it refuses to act on three conditions this method must refuse * on too: brute-force protection switched off, the IP whitelisted, or the * site in staging mode. Skipping that check here would let this button * report "mitigated" — written to the immutable audit hash chain, no * less — on a site where nothing was actually blocked. */ private function apply_block_ip( array $case ) { if ( ! GUARDFORGE_Case_Model::can_transition( (string) $case['status'], 'mitigated' ) ) { $this->redirect( (int) $case['id'], '', 'transition_not_allowed' ); return; } $ip = $this->case_ip( (int) $case['id'] ); if ( $ip === '' ) { $this->redirect( (int) $case['id'], '', 'no_evidence' ); return; } $refusal = $this->block_ip_refusal( $ip ); if ( $refusal !== '' ) { $this->redirect( (int) $case['id'], '', $refusal ); return; } $threshold = (int) GUARDFORGE_Options::get( 'bf_max_attempts' ); $minutes = max( 1, (int) GUARDFORGE_Options::get( 'bf_lockout_minutes' ) ); set_transient( GUARDFORGE_Bruteforce::attempts_key( $ip ), max( $threshold, 1 ), $minutes * MINUTE_IN_SECONDS ); $repo = GUARDFORGE_Cases::instance(); $repo->update_status( (int) $case['id'], 'mitigated' ); $summary = sprintf( 'IP %s blocked for %d minutes.', $ip, $minutes ); $repo->record_decision_event( (int) $case['id'], $summary, [ 'remedy' => 'block_ip', 'ip' => $ip, 'minutes' => $minutes ] ); if ( class_exists( 'GUARDFORGE_Audit_Log' ) ) { GUARDFORGE_Audit_Log::instance()->record( 'case_block_ip', 'case', (string) $case['id'], $summary ); } $this->redirect( (int) $case['id'], 'mitigated' ); } /** * The same three gates GUARDFORGE_Bruteforce::maybe_block_login itself * checks before a set transient ever turns into a real 429 — refusing * here on the same conditions, before the transient is even written, is * what keeps this button's "mitigated" honest. * * @return string '' when the block may proceed, otherwise the redirect * error code naming why it was refused. */ private function block_ip_refusal( $ip ) { if ( ! GUARDFORGE_Options::get( 'brute_force_protection' ) ) { return 'bf_protection_disabled'; } if ( class_exists( 'GUARDFORGE_Bruteforce' ) && GUARDFORGE_Bruteforce::instance()->is_whitelisted( $ip ) ) { return 'ip_whitelisted'; } if ( class_exists( 'GUARDFORGE_Staging' ) && GUARDFORGE_Staging::is_active() ) { GUARDFORGE_Staging::note_suppressed( 'lockout', $ip ); return 'staging_mode'; } return ''; } /** Type 3, "lockdown" — Pro only; toggles the same setting the Lockdown screen's own switch writes. */ private function apply_lockdown( array $case ) { if ( ! GUARDFORGE_Case_Model::can_transition( (string) $case['status'], 'mitigated' ) ) { $this->redirect( (int) $case['id'], '', 'transition_not_allowed' ); return; } if ( ! class_exists( 'GUARDFORGE_Lockdown' ) ) { $this->redirect( (int) $case['id'], '', 'pro_not_installed' ); return; } GUARDFORGE_Options::set( [ 'lockdown_active' => 1 ] ); /** * Same signal the Lockdown screen's own toggle fires — 'case' names * this source distinctly from 'manual' (the screen) and 'auto' (the * threshold triggers), for anything downstream that logs why. */ do_action( 'guardforge/lockdown_toggled', true, 'case', 'Enabled from case #' . (int) $case['id'] ); $repo = GUARDFORGE_Cases::instance(); $summary = 'Site-wide lockdown enabled.'; $repo->update_status( (int) $case['id'], 'mitigated' ); $repo->record_decision_event( (int) $case['id'], $summary, [ 'remedy' => 'lockdown' ] ); if ( class_exists( 'GUARDFORGE_Audit_Log' ) ) { GUARDFORGE_Audit_Log::instance()->record( 'lockdown_enabled', 'plugin', 'guardforge', $summary . ' (case #' . (int) $case['id'] . ')' ); } $this->redirect( (int) $case['id'], 'mitigated' ); } // ─────────────────────────── plumbing ───────────────────────────────── /** * Capability, nonce, and the case itself. Every handler starts here so * the four checks cannot drift apart between them. * * @return array|null Null after already having wp_die'd * or redirected — the caller returns immediately either way. */ private function authorize( $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Access denied.', 'guardforge' ) ); } check_admin_referer( $action ); $case_id = isset( $_POST['case_id'] ) ? absint( wp_unslash( $_POST['case_id'] ) ) : 0; $case = $case_id > 0 && class_exists( 'GUARDFORGE_Cases' ) ? GUARDFORGE_Cases::instance()->get_case( $case_id ) : null; if ( ! $case ) { wp_safe_redirect( admin_url( 'admin.php?page=guardforge-cases&gf_case_error=not_found' ) ); exit; } return $case; } /** * @param callable|null $after §6.4 K1-e: run after the transition is * recorded (audit + case_event written) but before the redirect — * every handler here ends in wp_safe_redirect+exit, so anything a * specific decision must still do (handle_expected_change's own * trusted-baseline write) has to happen from inside this hook, not * after the call, which the redirect's exit would never reach. */ private function transition( array $case, $target, $basis, $assigned_user, $audit_event, $summary, ?callable $after = null ) { if ( ! GUARDFORGE_Case_Model::can_transition( (string) $case['status'], $target ) ) { $this->redirect( (int) $case['id'], '', 'transition_not_allowed' ); return; } $repo = GUARDFORGE_Cases::instance(); $repo->update_status( (int) $case['id'], $target, $basis, $assigned_user ); $repo->record_decision_event( (int) $case['id'], $summary, [ 'to_status' => $target ] ); if ( class_exists( 'GUARDFORGE_Audit_Log' ) ) { GUARDFORGE_Audit_Log::instance()->record( $audit_event, 'case', (string) $case['id'], $summary ); } if ( $after !== null ) { $after(); } $this->redirect( (int) $case['id'], $target ); } /** The most recent evidence row from $source, payload decoded — '' fields default to empty rather than a missing key. */ private function latest_evidence_payload( $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 ? (array) $match['payload'] : null; } /** Every evidence row of a login_series case shares (ip, username) by construction — any one of them names it. */ private function case_ip( $case_id ) { $payload = $this->latest_evidence_payload( $case_id, 'login_log' ); return $payload ? (string) ( $payload['ip'] ?? '' ) : ''; } private function redirect( $case_id, $status = '', $error = '' ) { $args = [ 'page' => 'guardforge-cases', 'gf_case_id' => $case_id ]; if ( $status !== '' ) { $args['gf_case_status'] = $status; } if ( $error !== '' ) { $args['gf_case_error'] = $error; } wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); exit; } }