regex. * * @var array */ private static $compiled = array(); /** * Protected and sensitive pattern lists after their filters, per request. * * @var array */ private static $lists = array(); /** * Forget the per-request pattern caches (after changing a filter, and in tests). * * @since 1.0.0 * @return void */ public static function reset() { self::$compiled = array(); self::$lists = array(); } /** * Who probably owns an option (Support\Owners, which knows WordPress core's option names). * * @since 1.0.0 * @param string $name Option name. * @return array{type: string, slug: string, name: string, state: string, confidence: string} */ public static function owner( string $name ): array { return Owners::guess( $name, 'option' ); } /** * Whether an option is a transient (or a transient's timeout). * * @since 1.0.0 * @param string $name Option name. * @return bool */ public static function is_transient( string $name ): bool { return (bool) preg_match( '/^_(site_)?transient_/', MetaMask::fold( $name ) ); } /** * Patterns of options that are never changed here ("*" = anything; case and accents ignored). * * @since 1.0.0 * @return string[] */ public static function protected_patterns(): array { if ( isset( self::$lists['protected'] ) ) { return self::$lists['protected']; } global $wpdb; $base = array( // WordPress. 'siteurl', 'home', 'blogname', 'blogdescription', 'active_plugins', 'template', 'stylesheet', 'current_theme', 'cron', 'rewrite_rules', 'permalink_structure', 'WPLANG', 'sidebars_widgets', 'widget_*', 'db_version', 'initial_db_version', 'blog_charset', 'timezone_string', 'gmt_offset', 'date_format', 'time_format', 'start_of_week', 'show_on_front', 'page_on_front', 'page_for_posts', 'uninstall_plugins', 'recovery_keys', 'auto_update_*', 'site_icon', 'wp_force_deactivated_plugins', 'can_compress_scripts', 'alloptions', 'notoptions', 'fresh_site', 'recently_activated', 'wp_user_roles', 'wp_*_user_roles', // WooCommerce. 'woocommerce_version', 'woocommerce_db_version', 'woocommerce_permalinks', 'woocommerce_currency', 'woocommerce_default_country', 'woocommerce_*_page_id', 'woocommerce_custom_orders_table_*', 'woocommerce_feature_*', 'woocommerce_queue_flush_rewrite_rules', 'woocommerce_schema_version', 'woocommerce_store_id', // Action Scheduler (bundled with WooCommerce; an inactive standalone copy could claim these). 'action_scheduler_*', 'schema-ActionScheduler_*', // The Jetpack connection (WooCommerce bundles it; WooPayments and WooCommerce.com use its // tokens, read on every request while connected). 'jetpack_*', 'jetpack-*', // This plugin (the Registry sets our own autoload flags anyway). 'apwc_*', ); if ( isset( $wpdb->prefix ) ) { $base[] = $wpdb->prefix . 'user_roles'; } foreach ( array( get_stylesheet(), get_template() ) as $theme ) { if ( is_string( $theme ) && '' !== $theme ) { $base[] = 'theme_mods_' . $theme; } } /** * Filter the options the Autoloaded options analyzer never changes (stop autoloading or * delete). Exact names or patterns with "*" (any text); case and accents are ignored. Names * can only be added: the built-in list always stays. * * @since 1.0.0 * @param string[] $patterns Names or patterns. */ $added = apply_filters( 'apwc_protected_autoload_options', $base ); $added = is_array( $added ) ? array_filter( $added, static fn( $p ) => is_string( $p ) && '' !== trim( $p ) ) : array(); self::$lists['protected'] = array_values( array_unique( array_merge( $base, array_map( 'trim', $added ) ) ) ); return self::$lists['protected']; } /** * Why an option's autoload can't be switched off here, or '' when it can. * * @since 1.0.0 * @param string $name Option name (as stored). * @return string Plain text reason. */ public static function toggle_block( string $name ): string { if ( self::is_transient( $name ) ) { return __( 'Transients are temporary copies that WordPress or a plugin rebuilds. Clear them with WooCommerce → Status → Tools instead.', 'admin-powerups-for-woocommerce' ); } if ( self::matches( $name, self::protected_patterns(), 'protected' ) ) { return __( 'Protected: WordPress, WooCommerce or this plugin needs it on every page.', 'admin-powerups-for-woocommerce' ); } return ''; } /** * Why an option can't be deleted here, or '' when it can. * * @since 1.0.0 * @param string $name Option name (as stored). * @param array{type: string, slug: string, name: string, state: string, confidence: string} $owner Owner guess. * @return string Plain text reason. */ public static function delete_block( string $name, array $owner ): string { $blocked = self::toggle_block( $name ); if ( '' !== $blocked ) { return $blocked; } if ( self::matches( $name, array( 'woocommerce_*', 'woocommerce-*' ), 'woocommerce' ) || 'woocommerce' === ( $owner['type'] ?? '' ) ) { return __( 'WooCommerce option. WooCommerce removes its own options when you delete it with “Remove all data” on.', 'admin-powerups-for-woocommerce' ); } $type = (string) ( $owner['type'] ?? 'unknown' ); $state = (string) ( $owner['state'] ?? 'unknown' ); $confidence = (string) ( $owner['confidence'] ?? 'unknown' ); if ( 'core' === $type ) { return __( 'WordPress option. It can’t be deleted here.', 'admin-powerups-for-woocommerce' ); } if ( ! in_array( $type, array( 'plugin', 'theme' ), true ) || ! in_array( $confidence, array( 'exact', 'likely' ), true ) ) { return __( 'Owner unknown. It could belong to WordPress or an active plugin, so it can’t be deleted here. You can stop autoloading it instead.', 'admin-powerups-for-woocommerce' ); } if ( 'active' === $state ) { return 'theme' === $type ? __( 'Belongs to the active theme. Deleting it could break the theme.', 'admin-powerups-for-woocommerce' ) : __( 'Belongs to an active plugin. Deleting it could break that plugin.', 'admin-powerups-for-woocommerce' ); } if ( 'inactive' === $state ) { return ''; } if ( 'not-installed' === $state && 'plugin' === $type && self::is_known_leftover( $name, (string) ( $owner['slug'] ?? '' ) ) ) { return ''; } if ( 'not-installed' === $state && 'plugin' === $type ) { return __( 'The plugin that probably owns it isn’t installed, but the name isn’t one of its known options, so it can’t be deleted here. You can stop autoloading it instead.', 'admin-powerups-for-woocommerce' ); } return __( 'Only options of inactive or removed plugins and inactive themes can be deleted here.', 'admin-powerups-for-woocommerce' ); } /** * Exact option names well-known plugins leave behind, by plugin folder. Options of a plugin * that isn't installed can be deleted only when the name is on this list: a prefix alone can * be shared by another plugin or a bundled package (the Jetpack connection, for example). * Payment plugins aren't listed: their settings stay. * * @since 1.0.0 * @return array */ public static function known_leftovers(): array { return array( 'wordpress-seo' => array( 'wpseo', 'wpseo_titles', 'wpseo_social' ), 'seo-by-rank-math' => array( 'rank_math_modules', 'rank-math-options-general', 'rank-math-options-titles', 'rank-math-options-sitemap' ), 'all-in-one-seo-pack' => array( 'aioseo_options', 'aioseo_options_dynamic' ), 'elementor' => array( 'elementor_version', 'elementor_scheme_color', 'elementor_scheme_typography', 'elementor_scheme_color-picker' ), 'wpforms-lite' => array( 'wpforms_settings', 'wpforms_version', 'wpforms_activated' ), 'contact-form-7' => array( 'wpcf7' ), 'akismet' => array( 'akismet_strictness', 'akismet_show_user_comments_approved', 'akismet_comment_form_privacy_notice' ), 'wordfence' => array( 'wordfence_version' ), 'wp-rocket' => array( 'wp_rocket_settings' ), 'w3-total-cache' => array( 'w3tc_state' ), 'autoptimize' => array( 'autoptimize_version', 'autoptimize_css', 'autoptimize_js' ), 'updraftplus' => array( 'updraft_interval', 'updraft_retain', 'updraft_interval_database', 'updraft_retain_db' ), 'wp-mail-smtp' => array( 'wp_mail_smtp_version', 'wp_mail_smtp_activated_time' ), 'mailchimp-for-wp' => array( 'mc4wp_version' ), 'redirection' => array( 'redirection_options' ), 'instagram-feed' => array( 'sb_instagram_settings' ), ); } /** * Whether a name is exactly one of a not-installed plugin's well-known option names. * * @since 1.0.0 * @param string $name Option name. * @param string $slug Plugin folder from the owner guess. * @return bool */ public static function is_known_leftover( string $name, string $slug ): bool { $names = self::known_leftovers()[ strtolower( $slug ) ] ?? array(); return in_array( MetaMask::fold( $name ), array_map( array( MetaMask::class, 'fold' ), $names ), true ); } /** * A note for switching off an option that is probably read on every page (its owner is * WordPress, WooCommerce or an active plugin or theme), or ''. * * @since 1.0.0 * @param array $owner Owner guess. * @return string */ public static function stop_note( array $owner ): string { $type = $owner['type'] ?? 'unknown'; if ( in_array( $type, array( 'core', 'woocommerce' ), true ) || ( in_array( $type, array( 'plugin', 'theme' ), true ) && 'active' === ( $owner['state'] ?? '' ) ) ) { return __( 'If this is read on every page, stopping autoload adds a database query to each page.', 'admin-powerups-for-woocommerce' ); } return ''; } /** * Sensitive name patterns, for option names and for keys inside values. * * @since 1.0.0 * @return string[] */ public static function sensitive_patterns(): array { if ( isset( self::$lists['sensitive'] ) ) { return self::$lists['sensitive']; } $base = array( '*key*', '*secret*', '*token*', '*password*', '*pass*', '*license*', '*auth*', '*salt*', '*private*', '*credential*', '*api*' ); /** * Filter the name patterns ("*" = any text; case and accents ignored) that mask an option's * value in the Autoloaded options analyzer. They are checked against the option name and * against every key inside the stored value. Patterns can only be added. * * @since 1.0.0 * @param string[] $patterns Patterns. */ $added = apply_filters( 'apwc_sensitive_option_patterns', $base ); $added = is_array( $added ) ? array_filter( $added, static fn( $p ) => is_string( $p ) && '' !== trim( $p ) ) : array(); self::$lists['sensitive'] = array_values( array_unique( array_merge( $base, array_map( 'trim', $added ) ) ) ); return self::$lists['sensitive']; } /** * Whether an option's value must be masked: its name looks sensitive, a key inside the stored * value does (serialized strings, including private and protected property names, and JSON * keys; the value is never unserialized), or the value contains something that looks like a * secret key or token. * * @since 1.0.0 * @param string $name Option name. * @param string $raw Raw stored value. * @return bool */ public static function is_sensitive( string $name, string $raw ): bool { $patterns = self::sensitive_patterns(); if ( self::matches( $name, $patterns, 'sensitive' ) ) { return true; } if ( '' === $raw ) { return false; } if ( MetaMask::looks_like_secret( $raw ) ) { return true; } $keys = self::value_keys( $raw ); if ( null === $keys ) { return true; // Too many to check: mask to be safe. } foreach ( $keys as $key ) { if ( self::matches( (string) $key, $patterns, 'sensitive' ) ) { return true; } } return false; } /** * Extra name patterns (for option names and keys inside values) that mask a value for viewers * below the Advanced level, on top of sensitive_patterns(). * * @since 1.0.0 * @return string[] */ public static function below_advanced_patterns(): array { return array( '*pwd*', '*passwd*', '*cert*', '*dsn*', '*session*', '*cookie*', '*nonce*', '*bearer*', '*signature*', '*webhook*', '*creds*', '*hash*' ); } /** * Whether a value must be masked for someone below the Advanced level (who can't reveal it): * is_sensitive(), or the name or a key inside the value matches below_advanced_patterns(), or * the content holds a login in a URL (`scheme://user:pass@`) or a long random-looking token * (24+ letters and digits in a row, with both). Stricter on purpose: these viewers can't * reveal anything, so a guess errs towards hiding. * * @since 1.0.0 * @param string $name Option name. * @param string $raw Raw stored value. * @return bool */ public static function is_sensitive_below_advanced( string $name, string $raw ): bool { if ( self::is_sensitive( $name, $raw ) ) { return true; } $patterns = self::below_advanced_patterns(); if ( self::matches( $name, $patterns, 'below_advanced' ) ) { return true; } if ( '' === $raw ) { return false; } if ( preg_match( '#[a-z][a-z0-9+.\-]*://[^\s/@:"]+:[^\s/@"]+@#i', $raw ) || self::has_random_token( $raw ) ) { return true; } foreach ( (array) self::value_keys( $raw ) as $key ) { if ( self::matches( (string) $key, $patterns, 'below_advanced' ) ) { return true; } } return false; } /** * Whether a value holds a long random-looking token: 24 or more letters and digits in a row * (not part of a longer word), with at least two of each, such as API keys, hex digests and * license keys. Words joined by `_`, `-`, `/` or `.` (names, paths, URLs) don't count. * * @since 1.0.0 * @param string $raw Raw value. * @return bool */ public static function has_random_token( string $raw ): bool { if ( ! preg_match_all( '/(? self::MAX_VALUE_KEYS ? null : $keys; } /** * Collect the string keys of a decoded JSON value (stops early past MAX_VALUE_KEYS). * * @since 1.0.0 * @param array $value Decoded value. * @param string[] $keys Keys found so far. * @return void */ private static function walk_keys( array $value, array &$keys ) { foreach ( $value as $key => $item ) { if ( count( $keys ) > self::MAX_VALUE_KEYS ) { return; } if ( is_string( $key ) ) { $keys[] = $key; } if ( is_array( $item ) ) { self::walk_keys( $item, $keys ); } } } /** * Whether a name matches any pattern ("*" = anything), folded like the options table's * collation (MetaMask::fold(): case, accents, full-width letters, ligatures). The patterns * are compiled into one regex, cached per request under `$cache` when given. * * @since 1.0.0 * @param string $name Name. * @param string[] $patterns Patterns. * @param string $cache Cache key for this pattern list ('' = don't cache). * @return bool */ public static function matches( string $name, array $patterns, string $cache = '' ): bool { if ( '' !== $cache && isset( self::$compiled[ $cache ] ) ) { $regex = self::$compiled[ $cache ]; } else { $parts = array(); foreach ( $patterns as $pattern ) { $parts[] = str_replace( '\*', '.*', preg_quote( MetaMask::fold( (string) $pattern ), '/' ) ); } $regex = $parts ? '/^(?:' . implode( '|', $parts ) . ')$/s' : ''; if ( '' !== $cache ) { self::$compiled[ $cache ] = $regex; } } return '' !== $regex && (bool) preg_match( $regex, MetaMask::fold( $name ) ); } }