bool|int|string|email|textarea|csv, 'default' => mixed]. * Used by `set` to sanitise, and by tests to enumerate. */ public static function schema() { return [ // API & Protocols 'disable_xmlrpc' => [ 'type' => 'bool', 'default' => 1 ], 'disable_rest_api' => [ 'type' => 'bool', 'default' => 1 ], 'rest_allowlist' => [ 'type' => 'textarea', 'default' => "oembed/1.0\nformforge/v1\nguardforge/v1" ], 'disable_rest_comments' => [ 'type' => 'bool', 'default' => 1 ], 'disable_embed' => [ 'type' => 'bool', 'default' => 0 ], 'disable_pingback' => [ 'type' => 'bool', 'default' => 1 ], // Hardening 'hide_wp_version' => [ 'type' => 'bool', 'default' => 1 ], 'disable_file_edit' => [ 'type' => 'bool', 'default' => 1 ], 'disable_user_enumeration' => [ 'type' => 'bool', 'default' => 1 ], 'security_headers' => [ 'type' => 'bool', 'default' => 1 ], 'login_captcha_math' => [ 'type' => 'bool', 'default' => 0 ], // Files & Directories 'protect_sensitive_files' => [ 'type' => 'bool', 'default' => 1 ], 'disable_directory_listing' => [ 'type' => 'bool', 'default' => 1 ], // Spam & Bots 'honeypot_comments' => [ 'type' => 'bool', 'default' => 1 ], 'block_bad_useragents' => [ 'type' => 'bool', 'default' => 1 ], 'bad_useragents_extra' => [ 'type' => 'textarea', 'default' => '' ], // Brute-force 'brute_force_protection' => [ 'type' => 'bool', 'default' => 1 ], 'bf_log_enabled' => [ 'type' => 'bool', 'default' => 1 ], 'notify_on_lockout' => [ 'type' => 'bool', 'default' => 0 ], 'bf_max_attempts' => [ 'type' => 'int', 'default' => 5, 'min' => 1, 'max' => 100 ], 'bf_lockout_minutes' => [ 'type' => 'int', 'default' => 30, 'min' => 1, 'max' => 10080 ], 'bf_log_keep_days' => [ 'type' => 'int', 'default' => 30, 'min' => 1, 'max' => 365 ], 'bf_whitelist_ips' => [ 'type' => 'textarea', 'default' => '' ], 'bf_trusted_proxies' => [ 'type' => 'textarea', 'default' => '' ], // CIDR allowlist for X-Forwarded-For trust. 'bf_trust_cloudflare' => [ 'type' => 'bool', 'default' => 1 ], // Auto-trust CF ranges if X-Forwarded-For seen. // Staging / development mode. Both keys pass the one rule that // decides whether a staging signal may live in the database: a // copied value must fail SAFE. 'staging_force_production' can only // make the plugin more protective. 'staging_hosts' is matched // against the LIVE host, so a staging→production database copy // stops matching the moment the domain changes. A // staging_mode_enabled boolean would fail that rule — copy the row // onto production and the security plugin is silently disarmed — // so the only manual on-switch is the GUARDFORGE_STAGING_MODE // constant in wp-config.php, a file no dump can carry. 'staging_hosts' => [ 'type' => 'textarea', 'default' => '' ], 'staging_force_production' => [ 'type' => 'bool', 'default' => 0 ], // Integrity // Off by default (review of 23.09.2026, Guideline 7): the daily scan // asks api.wordpress.org for the official checksums, and activation // may neither make such a request nor schedule one. 'integrity_monitor' => [ 'type' => 'bool', 'default' => 0 ], 'integrity_notify' => [ 'type' => 'bool', 'default' => 1 ], 'integrity_scope_extras' => [ 'type' => 'bool', 'default' => 0 ], // Include plugins + themes (free). 'integrity_last_scan' => [ 'type' => 'string', 'default' => '' ], // Vulnerability feed. Off by default, and activation leaves it // that way: it is the one thing in the free plugin that reaches an // Avakode server on a schedule rather than on a button press, so // the owner has to ask for it first (Guideline 7). 'vulndb_feed_enabled' => [ 'type' => 'bool', 'default' => 0 ], // Audit 'audit_log_enabled' => [ 'type' => 'bool', 'default' => 1 ], 'audit_notify_new_admin' => [ 'type' => 'bool', 'default' => 1 ], 'audit_log_keep_days' => [ 'type' => 'int', 'default' => 60, 'min' => 1, 'max' => 365 ], // CSP 'csp_enabled' => [ 'type' => 'bool', 'default' => 0 ], // Off by default — too easy to break the site. 'csp_report_only' => [ 'type' => 'bool', 'default' => 1 ], 'csp_policy' => [ 'type' => 'textarea', 'default' => "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';", 'max' => 5000 ], // Notifications 'notify_email' => [ 'type' => 'email', 'default' => '' ], 'alert_channels' => [ 'type' => 'textarea', 'default' => '' ], // JSON-encoded per-channel config — Pro. // Alerts delivery policy (Pro). digest_mode is validated against // GUARDFORGE_Alerts_Policy::DIGEST_MODES by the save handler, not // by this schema — the schema has no enum type, and that // validation lives with the feature in pro/includes/class-alerts.php. // Quiet-hours start/end are "HH:MM" in the site's own timezone. 'alerts_digest_mode' => [ 'type' => 'string', 'default' => 'immediate' ], 'alerts_quiet_hours_enabled' => [ 'type' => 'bool', 'default' => 0 ], 'alerts_quiet_hours_start' => [ 'type' => 'string', 'default' => '22:00' ], 'alerts_quiet_hours_end' => [ 'type' => 'string', 'default' => '07:00' ], // Stored preference for the Pro enforcement policy. 2FA itself is // Free; only the code that enforces it on other administrators // lives in the add-on, and only that code reads this key. 'require_2fa_for_admins' => [ 'type' => 'bool', 'default' => 0 ], // Pro: lockdown 'lockdown_active' => [ 'type' => 'bool', 'default' => 0 ], // Pro: auto-lockdown (GF-3). Reacts to two of GuardForge's own // signals — a burst of distinct IPs crossing the brute-force // threshold, and a malware-scanner finding — by throwing the same // switch as the manual button above. The 10-minute window the // threshold counts over is not a setting: it is short enough that // a stored option for it would just be one more number an // operator has to understand before the feature does anything // sensible, and it can move to a filter later if a real site // needs a different one. 'auto_lockdown_enabled' => [ 'type' => 'bool', 'default' => 0 ], 'auto_lockdown_on_threshold' => [ 'type' => 'bool', 'default' => 1 ], 'auto_lockdown_ip_threshold' => [ 'type' => 'int', 'default' => 20, 'min' => 1, 'max' => 1000 ], 'auto_lockdown_on_malware' => [ 'type' => 'bool', 'default' => 0 ], 'auto_lockdown_lift_minutes' => [ 'type' => 'int', 'default' => 60, 'min' => 1, 'max' => 10080 ], // Pro: geo 'geo_block_countries' => [ 'type' => 'csv', 'default' => '' ], // ISO 3166-1 alpha-2 codes. 'geo_mode' => [ 'type' => 'string', 'default' => 'blocklist' ], // blocklist|allowlist // Cloudflare firewall sync (Pro). The token belongs to the site // owner and stays on their site, encrypted at rest — we never hold // a customer's Cloudflare credentials on our side. Scope it to // Zone → Firewall Services → Edit on the one zone. 'cf_zone_id' => [ 'type' => 'string', 'default' => '' ], 'cf_api_token' => [ 'type' => 'secret', 'default' => '' ], 'cf_block_days' => [ 'type' => 'int', 'default' => 30, 'min' => 1, 'max' => 365 ], // Pro: patch by update. Stored here because every option in this // plugin is, and because the free package has to be able to // sanitise a value it did not write — the add-on posts the form // through the free settings handler. Nothing free reads these // keys; the rule that does ships in guardforge-pro // (GUARDFORGE_Patch_Rule). Default off: a site does not start // installing other people's code because it was updated. 'patch_auto_update' => [ 'type' => 'bool', 'default' => 0 ], 'patch_allow_slugs' => [ 'type' => 'textarea', 'default' => '' ], 'patch_deny_slugs' => [ 'type' => 'textarea', 'default' => '' ], 'vulndb_pro_updates' => [ 'type' => 'bool', 'default' => 1 ], // The hourly AI batch spends the site's credits, so it waits to be // asked. Read by GUARDFORGE_Ai_Threat in the Pro add-on; the key // lives here because the options schema is one document. 'ai_threat_enabled' => [ 'type' => 'bool', 'default' => 0 ], // Housekeeping. Off by default: the login log, the integrity // snapshots and the audit trail are the operator's history, and a // plugin deleted by accident should not take them with it. The // two-factor table is the deliberate exception and leaves whatever // this says — see uninstall.php for why. 'delete_data_on_uninstall' => [ 'type' => 'bool', 'default' => 0 ], ]; } public static function defaults() { $out = []; foreach ( self::schema() as $key => $spec ) { $out[ $key ] = $spec['default']; } return $out; } public static function all() { $stored = get_option( self::OPTION_KEY, [] ); if ( ! is_array( $stored ) ) { $stored = []; } $defaults = self::defaults(); $merged = wp_parse_args( $stored, $defaults ); foreach ( self::schema() as $key => $spec ) { if ( isset( $spec['max'], $merged[ $key ] ) && is_string( $merged[ $key ] ) && strlen( $merged[ $key ] ) > (int) $spec['max'] ) { $merged[ $key ] = $defaults[ $key ]; } } return $merged; } public static function get( $key, $default = null ) { $all = self::all(); if ( array_key_exists( $key, $all ) ) { return $all[ $key ]; } return $default; } /** * Read a 'secret' field back as plaintext. Returns '' when unset or when * the stored ciphertext no longer decrypts (salts rotated) — callers treat * that the same as "not configured". * * @param string $key * @return string */ public static function get_secret( $key ) { $stored = (string) self::get( $key, '' ); if ( $stored === '' ) { return ''; } if ( ! class_exists( 'Forge_Crypto' ) ) { require_once GUARDFORGE_DIR . 'includes/class-forge-crypto.php'; } return (string) Forge_Crypto::decrypt( $stored ); } /** * Sanitise a single field per its schema spec. Public so tests can poke it * without going through the option round-trip. * * @param string $key * @param mixed $value * @return mixed */ public static function sanitise_field( $key, $value ) { $schema = self::schema(); if ( ! isset( $schema[ $key ] ) ) { return $value; } $spec = $schema[ $key ]; switch ( $spec['type'] ) { case 'bool': return ! empty( $value ) ? 1 : 0; case 'int': $v = (int) $value; if ( isset( $spec['min'] ) ) { $v = max( $spec['min'], $v ); } if ( isset( $spec['max'] ) ) { $v = min( $spec['max'], $v ); } return $v; case 'email': return is_string( $value ) ? sanitize_email( $value ) : ''; case 'textarea': $text = is_string( $value ) ? sanitize_textarea_field( wp_unslash( $value ) ) : ''; if ( isset( $spec['max'] ) ) { $text = substr( $text, 0, (int) $spec['max'] ); } return $text; case 'csv': if ( is_array( $value ) ) { $value = implode( ',', $value ); } if ( ! is_string( $value ) ) { return ''; } $parts = array_filter( array_map( 'trim', explode( ',', strtoupper( $value ) ) ) ); return implode( ',', $parts ); case 'secret': // Stored encrypted; an empty submit means "leave what is // there" so the settings form never has to echo the token // back into the page to preserve it. if ( ! is_string( $value ) || $value === '' ) { return self::get( $key, '' ); } $clean = sanitize_text_field( $value ); if ( $clean === '' ) { return ''; } if ( ! class_exists( 'Forge_Crypto' ) ) { require_once GUARDFORGE_DIR . 'includes/class-forge-crypto.php'; } return Forge_Crypto::encrypt( $clean ); case 'string': default: return is_string( $value ) ? sanitize_text_field( $value ) : ''; } } /** * Which option keys each secondary settings screen owns. * * A screen that posts only its own fields must not be read as "everything * else was unchecked" — before this map, saving the Geo-IP tab silently * switched off every hardening checkbox on the main tab, because an absent * checkbox and an unchecked one look identical in a POST body. */ public static function screens() { return [ // The main tab is in this map for the same reason the others are: // it renders none of their checkboxes, so a whole-schema write // from here read as "every secondary screen was switched off". 'main' => [ 'disable_xmlrpc', 'disable_rest_api', 'rest_allowlist', 'disable_rest_comments', 'disable_embed', 'disable_pingback', 'hide_wp_version', 'disable_file_edit', 'disable_user_enumeration', 'security_headers', 'login_captcha_math', 'protect_sensitive_files', 'disable_directory_listing', 'honeypot_comments', 'block_bad_useragents', 'bad_useragents_extra', 'brute_force_protection', 'bf_log_enabled', 'notify_on_lockout', 'bf_max_attempts', 'bf_lockout_minutes', 'bf_log_keep_days', 'bf_whitelist_ips', 'bf_trusted_proxies', 'staging_hosts', 'staging_force_production', 'integrity_monitor', 'integrity_notify', 'integrity_scope_extras', 'vulndb_feed_enabled', 'audit_log_enabled', 'audit_notify_new_admin', 'audit_log_keep_days', 'csp_enabled', 'csp_report_only', 'csp_policy', 'notify_email', 'delete_data_on_uninstall', ], 'geo' => [ 'geo_mode', 'geo_block_countries' ], // GUARDFORGE_Alerts::handle_save_form writes through this same // map (Options::set), even though it posts to its own // admin_post action rather than the generic settings handler. 'alerts' => [ 'alert_channels', 'alerts_digest_mode', 'alerts_quiet_hours_enabled', 'alerts_quiet_hours_start', 'alerts_quiet_hours_end' ], '2fa' => [ 'require_2fa_for_admins' ], 'cf' => [ 'cf_zone_id', 'cf_api_token', 'cf_block_days' ], 'lockdown' => [ 'auto_lockdown_enabled', 'auto_lockdown_on_threshold', 'auto_lockdown_ip_threshold', 'auto_lockdown_on_malware', 'auto_lockdown_lift_minutes', ], 'patch' => [ 'patch_auto_update', 'patch_allow_slugs', 'patch_deny_slugs' ], 'vulndb_pro' => [ 'vulndb_pro_updates' ], 'ai' => [ 'ai_threat_enabled' ], ]; } /** * Persist a sanitised partial update. Missing checkboxes are interpreted * as 0 only when `$treat_missing_bools_as_off` is true (used by the * settings form which posts a whole tab at once). * * @param array $values * @param bool $treat_missing_bools_as_off * @param array $only_keys Restrict the write to these schema keys. Empty * means "the whole schema" (the main settings tab). */ public static function set( array $values, $treat_missing_bools_as_off = false, array $only_keys = [] ) { $schema = self::schema(); if ( $only_keys ) { $schema = array_intersect_key( $schema, array_flip( $only_keys ) ); } $current = self::all(); $next = $current; foreach ( $schema as $key => $spec ) { if ( array_key_exists( $key, $values ) ) { $next[ $key ] = self::sanitise_field( $key, $values[ $key ] ); } elseif ( $treat_missing_bools_as_off && $spec['type'] === 'bool' ) { $next[ $key ] = 0; } } update_option( self::OPTION_KEY, $next ); return $next; } /** * Reset to defaults — used by uninstall.php and tests. */ public static function reset() { delete_option( self::OPTION_KEY ); } }