$active, 'reason' => $reason, 'detail' => $detail ]; } /** Memoised for the request. Never persisted — see the class docblock. */ public static function is_active() { if ( self::$decision === null ) { self::$decision = self::detect(); } return (bool) self::$decision['active']; } public static function reason() { if ( self::$decision === null ) { self::$decision = self::detect(); } return (string) self::$decision['reason']; } public static function detail() { if ( self::$decision === null ) { self::$decision = self::detect(); } return (string) self::$decision['detail']; } /** Drop the per-request memo. For tests, and for nothing else. */ public static function flush_static() { self::$decision = null; } /** * Match a host against the operator's list. * * Hand-written rather than fnmatch because the two mistakes that matter * are both things a looser matcher does happily. A substring test makes * 'notstaging.example.com' match 'staging.example.com'. A wildcard that * covers the apex makes '.example.com' match 'example.com', which is the * production site — the one host on earth this list must never match by * accident. * * A leading dot therefore matches subdomains and only subdomains. Nothing * else is a pattern: an entry either equals the host or it does not. * 'www.' is never stripped, because equating two different host names is * how a list quietly grows to cover something the operator did not write. * * @param string $host Live host, already resolved. * @param string|null $raw Raw list; defaults to the stored option. * @return string The entry that matched, or '' for no match. */ public static function match_host( $host, $raw = null ) { $host = strtolower( trim( (string) $host ) ); if ( $host === '' ) { return ''; } if ( $raw === null ) { $raw = (string) GUARDFORGE_Options::get( 'staging_hosts', '' ); } foreach ( preg_split( '/[\r\n,]+/', (string) $raw ) as $line ) { $entry = self::normalise_entry( $line ); if ( $entry === '' ) { continue; } if ( $entry === $host ) { return $entry; } $len = strlen( $entry ); if ( $entry[0] === '.' && $len > 1 && strlen( $host ) > $len && substr( $host, -$len ) === $entry ) { return $entry; } } return ''; } /** * Reduce one line of the list to a bare host name. * * An operator who pastes 'https://staging.example.com/' has written down * the right site, and a matcher that silently never fires on it teaches * them the feature is broken rather than that their entry is. So a scheme, * a path and a port are trimmed off. Nothing else is: the result still has * to equal the host, or begin with a dot and be a suffix of it. */ private static function normalise_entry( $line ) { $entry = strtolower( trim( (string) $line ) ); if ( $entry === '' || $entry[0] === '#' ) { return ''; } $scheme = strpos( $entry, '//' ); if ( $scheme !== false ) { $entry = substr( $entry, $scheme + 2 ); } $slash = strpos( $entry, '/' ); if ( $slash !== false ) { $entry = substr( $entry, 0, $slash ); } // Only a numeric :port. An IPv6 literal is full of colons and must // survive this untouched. $entry = (string) preg_replace( '/:\d+$/', '', $entry ); return trim( $entry ); } /** The host WordPress believes it is serving, lower-cased. */ public static function current_host() { if ( ! function_exists( 'home_url' ) || ! function_exists( 'wp_parse_url' ) ) { return ''; } return strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); } /** * Core's environment type, or 'production'. * * readme.txt requires WordPress 6.5, so the function is always there on a * real site; the guard is for the unit suite, where a module is loaded * without a WordPress behind it and the safe answer is the armed one. */ private static function environment_type() { if ( ! function_exists( 'wp_get_environment_type' ) ) { return 'production'; } return (string) wp_get_environment_type(); } // ───────────────────────── suppression ────────────────────────── /** * Record that one egress was refused. * * The counter is what the banner and the settings screen read, so it * moves on every single refusal. The audit row does not: a spray against * a staging box would otherwise write one audit row per failed password, * and the audit log is where an operator looks for the unusual. One row * per channel per hour says the same thing and stays readable. * * @param string $channel One of self::CHANNELS. * @param string $detail Free-text context for the audit row. */ public static function note_suppressed( $channel, $detail = '' ) { $channel = (string) $channel; if ( ! in_array( $channel, self::CHANNELS, true ) ) { return; } $counters = self::counters(); $counters[ $channel ] += 1; if ( empty( $counters['since'] ) ) { $counters['since'] = time(); } update_option( self::SUPPRESSED_OPTION, $counters, false ); /** * Fires every time staging mode refuses an egress. * * @param string $channel One of GUARDFORGE_Staging::CHANNELS. * @param array $context reason + detail. */ do_action( 'guardforge/staging_suppressed', $channel, [ 'reason' => self::reason(), 'detail' => (string) $detail, ] ); $key = self::NOTE_TRANSIENT_PREFIX . $channel; if ( get_transient( $key ) ) { return; } set_transient( $key, 1, HOUR_IN_SECONDS ); // Guarded because this is also called from the Pro add-on, and // because a fatal inside a suppression path would take down the very // request this mode exists to keep uneventful. if ( class_exists( 'GUARDFORGE_Audit_Log' ) ) { GUARDFORGE_Audit_Log::instance()->record( 'staging_suppressed', $channel, self::reason(), (string) $detail ); } } /** * Per-channel tally, with every channel present so callers never have to * test for a key that has simply never been hit. * * @return array */ /** * Which channels this install actually holds back. * * Core owns the first two. Every other guard lives inside the add-on, so * a copy of GuardForge Pro from before staging mode existed suppresses * nothing — and neither the banner nor the settings panel may promise on * its behalf. The add-on says what it guards by filtering this list. */ public static function guarded_channels() { $guarded = apply_filters( 'guardforge/staging_guarded_channels', [ 'lockout', 'email' ] ); return array_values( array_intersect( self::CHANNELS, (array) $guarded ) ); } public static function counters() { $stored = get_option( self::SUPPRESSED_OPTION, [] ); if ( ! is_array( $stored ) ) { $stored = []; } $out = []; foreach ( self::CHANNELS as $channel ) { $out[ $channel ] = isset( $stored[ $channel ] ) ? (int) $stored[ $channel ] : 0; } $out['since'] = isset( $stored['since'] ) ? (int) $stored['since'] : 0; return $out; } /** Sum of everything held back, for the one-line summary. */ public static function suppressed_total() { $total = 0; foreach ( self::counters() as $key => $value ) { if ( $key !== 'since' ) { $total += (int) $value; } } return $total; } // ───────────────────────── the record ─────────────────────────── /** * Notice a transition and write it down once. * * admin_init only. A staging site nobody logs into records nothing until * somebody opens wp-admin, which is the deliberate trade: the alternative * is an option write on every front-end request of every site that has * this plugin, to observe a state that changes about twice a year. */ public function observe_transition() { $now = self::detect(); $host = self::current_host(); $prev = get_option( self::STATE_OPTION, [] ); if ( ! is_array( $prev ) ) { $prev = []; } $prev_active = ! empty( $prev['active'] ); $first = $prev === []; $changed = $first || $prev_active !== $now['active'] || (string) ( $prev['reason'] ?? '' ) !== $now['reason'] || (string) ( $prev['host'] ?? '' ) !== $host; if ( ! $changed ) { return; } update_option( self::STATE_OPTION, [ 'active' => $now['active'], 'reason' => $now['reason'], 'detail' => $now['detail'], 'host' => $host, // 'since' survives a reason change so the screen can still say how // long the mode has been on; only the flip itself restarts it. 'since' => ( ! $first && $prev_active === $now['active'] && ! empty( $prev['since'] ) ) ? (int) $prev['since'] : time(), ], false ); if ( $now['active'] ) { self::record_transition( 'staging_mode_activated', $now ); } elseif ( $prev_active ) { self::record_transition( 'staging_mode_deactivated', $now ); } else { // First sight of an ordinary production site. Nothing happened, // so nothing is announced — an audit row saying "staging mode // deactivated" on a site that was never in staging is a lie. return; } /** * Fires once, on the request that first sees the mode change. * * @param bool $active * @param string $reason * @param string $detail */ do_action( 'guardforge/staging_mode_changed', $now['active'], $now['reason'], $now['detail'] ); } private static function record_transition( $event_type, array $state ) { if ( ! class_exists( 'GUARDFORGE_Audit_Log' ) ) { return; } GUARDFORGE_Audit_Log::instance()->record( $event_type, 'staging', self::current_host(), $state['reason'] . ( $state['detail'] !== '' ? ' (' . $state['detail'] . ')' : '' ) ); } /** * Everything a screen needs, resolved once. * * @return array{active:bool,reason:string,detail:string,host:string,sentence:string,counters:array,total:int,since:int} */ public static function snapshot() { $state = get_option( self::STATE_OPTION, [] ); return [ 'active' => self::is_active(), 'reason' => self::reason(), 'detail' => self::detail(), 'host' => self::current_host(), 'sentence' => self::reason_sentence(), 'counters' => self::counters(), 'total' => self::suppressed_total(), 'since' => is_array( $state ) && ! empty( $state['since'] ) ? (int) $state['since'] : 0, ]; } // ───────────────────────── the announcement ───────────────────── /** One sentence naming the signal that decided it. */ public static function reason_sentence() { $detail = self::detail(); switch ( self::reason() ) { case 'constant': return __( 'GUARDFORGE_STAGING_MODE is set to true in wp-config.php.', 'guardforge' ); case 'constant_production': return __( 'GUARDFORGE_STAGING_MODE is set to false in wp-config.php, which pins this site to production.', 'guardforge' ); case 'environment_type': /* translators: %s is the WordPress environment type, e.g. "staging" or "local". */ return sprintf( __( 'WordPress reports this environment as "%s".', 'guardforge' ), $detail ); case 'host_list': /* translators: %s is the host-name entry that matched, e.g. "staging.example.com". */ return sprintf( __( 'This site answers to %s, which is on your list of staging host names.', 'guardforge' ), $detail ); case 'forced_production': return __( 'Something here looks like a copy, but "Always treat this site as production" is switched on.', 'guardforge' ); case 'filtered_off': return __( 'Something here looks like a copy, but code on this site cancelled staging mode.', 'guardforge' ); default: return __( 'Nothing about this site suggests it is a copy, so every protection is enforced normally.', 'guardforge' ); } } /** * The persistent banner. * * Core's own `notice notice-warning` markup rather than this plugin's * gf-notice, because assets/css/admin.css is only enqueued on GuardForge * screens — a gf- class anywhere else renders as unstyled text. The gf- * class rides along so the GuardForge screens can still pick it up. * * Shown to edit_posts and up. An editor who wonders why nothing blocked * them, or why their password reset never arrived, deserves the answer; * the counters and the settings link are administrators-only. Not * dismissible: a copy that has stopped protecting itself is not a thing * to be reminded of once. */ public function render_banner() { // Our own screens, the plugin list and the dashboard only (Guideline 11, // review of 20.09.2026): the banner explains why this copy holds its // outbound actions, which is worth saying where the reader can act on // it and noise on every other screen of the admin. if ( class_exists( 'GUARDFORGE_Core' ) && ! GUARDFORGE_Core::notice_screen_allows() ) { return; } if ( ! self::is_active() || ! current_user_can( 'edit_posts' ) ) { return; } $guarded = self::guarded_channels(); echo '

'; esc_html_e( 'GuardForge is in staging mode.', 'guardforge' ); echo ' '; // Only the two channels core itself holds back are unconditional; the // rest are the add-on's, and an add-on that predates staging mode has // no guard for the banner to describe. esc_html_e( 'Lockouts are recorded but not enforced, and no e-mail leaves this site.', 'guardforge' ); if ( in_array( 'alert', $guarded, true ) ) { echo ' '; esc_html_e( 'No alert leaves it either.', 'guardforge' ); } if ( in_array( 'cloudflare', $guarded, true ) ) { echo ' '; esc_html_e( 'No firewall rule is written to your zone.', 'guardforge' ); } echo ' ' . esc_html( self::reason_sentence() ) . '

'; if ( current_user_can( 'manage_options' ) ) { $total = self::suppressed_total(); echo '

'; if ( $total > 0 ) { printf( /* translators: %d is how many e-mails, alerts, firewall rules and lockouts have been held back. */ esc_html( _n( '%d thing has been held back so far.', '%d things have been held back so far.', $total, 'guardforge' ) ), (int) $total ); echo ' '; } printf( '%s', esc_url( admin_url( 'admin.php?page=guardforge' ) ), esc_html__( 'Review staging mode', 'guardforge' ) ); echo '

'; } echo '
'; } /** * The half that makes it unmissable: the admin bar is on the front end * too, so a developer clicking around the copy sees it without ever * opening wp-admin. * * @param mixed $bar WP_Admin_Bar. */ public function admin_bar_node( $bar ) { if ( ! self::is_active() || ! current_user_can( 'edit_posts' ) ) { return; } if ( ! is_object( $bar ) || ! method_exists( $bar, 'add_node' ) ) { return; } $bar->add_node( [ 'id' => 'guardforge-staging', // WP_Admin_Bar echoes `title` as raw HTML (it escapes meta['title'] // for the tooltip, but not this), so the escaping is ours to do. 'title' => esc_html__( 'GuardForge: staging mode', 'guardforge' ), 'href' => current_user_can( 'manage_options' ) ? admin_url( 'admin.php?page=guardforge' ) : '', 'meta' => [ 'title' => self::reason_sentence() ], ] ); } /** * One line on wp-login.php for the developer who is over the threshold. * * Without it the soft lockout is invisible from the only screen where it * would have mattered: the page just keeps accepting attempts, and the * fact that production would have shut this address out never reaches * the person who needs to know it before they deploy. * * @param string $message * @return string */ public function login_message( $message ) { if ( ! self::is_active() ) { return $message; } if ( ! class_exists( 'GUARDFORGE_Bruteforce' ) || ! class_exists( 'GUARDFORGE_Ip' ) ) { return $message; } if ( ! GUARDFORGE_Options::get( 'brute_force_protection' ) ) { return $message; } $bf = GUARDFORGE_Bruteforce::instance(); $ip = GUARDFORGE_Ip::client_ip(); if ( $bf->is_whitelisted( $ip ) ) { return $message; } if ( $bf->get_attempts( $ip ) < (int) GUARDFORGE_Options::get( 'bf_max_attempts' ) ) { return $message; } return $message . '

' . esc_html__( 'GuardForge staging mode: this address is over the failed-login threshold and would have been locked out on production.', 'guardforge' ) . '

'; } }