patterns + rule hash, time => built at, shown => exact stored keys that are not * masked, hidden => exact stored keys that are ]. */ const KEYS_OPTION = 'apwc_meta_mask_keys'; /** * Background job (Action Scheduler) that rebuilds KEYS_OPTION. */ const KEYS_JOB = 'apwc_meta_mask_keys_refresh'; /** * Most distinct keys checked for KEYS_OPTION (the most used ones). */ const KEYS_MAX = 1000; /** * Bump when fold() or the SQL rule changes, so keys checked by an older rule are checked again. */ const RULE_VERSION = 4; /** * Characters MySQL's `=` folds or ignores but LIKE compares one by one: removed or expanded * in SQL before the LIKE (and in PHP by fold()). */ const SQL_FOLD = array( "\u{200B}" => '', "\u{200C}" => '', "\u{200D}" => '', "\u{2060}" => '', "\u{FEFF}" => '', 'ı' => 'i', "\u{00AD}" => '', 'ß' => 'ss', "\u{1E9E}" => 'ss', "\u{FB00}" => 'ff', "\u{FB01}" => 'fi', "\u{FB02}" => 'fl', "\u{FB03}" => 'ffi', "\u{FB04}" => 'ffl', "\u{FB05}" => 'st', "\u{FB06}" => 'st', ); /** * Default sensitive key patterns. * * @since 1.0.0 * @return string[] Wildcard patterns ("*" = anything). */ public static function default_patterns(): array { return array( '*token*', '*secret*', '*password*', '*passwd*', '*api_key*', '*apikey*', '*private*', '*signature*', '*card_number*', '*cvv*', '*cvc*', '*iban*', '*account_number*', '*routing*', '*ssn*', '*tax_id*' ); } /** * Non-empty trimmed lines of a text (the settings' one-pattern-per-line format). * * @since 1.0.0 * @param string $text Text. * @return string[] */ public static function lines( string $text ): array { return array_values( array_filter( array_map( 'trim', explode( "\n", $text ) ) ) ); } /** * Sensitive key patterns as saved (defaults when never saved). * * @since 1.0.0 * @return string[] */ public static function patterns(): array { $saved = self::setting( 'sensitive' ); return null === $saved ? self::default_patterns() : self::lines( (string) $saved ); } /** * Key patterns masked in scheduled action and cron arguments: the saved sensitive patterns * (Order meta inspector) plus keys that carry order keys, auth data, nonces, sessions and * cookies. Only values under string keys can be masked; positional arguments are shown as is. * Shared by the Scheduled action monitor and the Cron inspector. * * @since 1.0.0 * @param string $context `action` (scheduled actions), `cron` (WP-Cron events) or `cleanup` * (Database clean-up samples). * @return string[] */ public static function arg_patterns( string $context = 'action' ): array { $patterns = array_merge( self::patterns(), array( '*order_key*', '*auth*', '*nonce*', '*session*', '*cookie*' ) ); /** * Filter the key patterns ("*" = anything, case and accents ignored) whose values are * masked in scheduled action and cron arguments. Only values under string keys are * masked, never positional arguments. * * @since 1.0.0 * @param string[] $patterns Patterns: the sensitive keys saved in Order meta plus * `*order_key*`, `*auth*`, `*nonce*`, `*session*`, `*cookie*`. * @param string $context `action`, `cron` or `cleanup` (Database clean-up samples, since 1.0.0). */ $patterns = apply_filters( 'apwc_sensitive_arg_patterns', array_values( array_unique( $patterns ) ), $context ); return array_values( array_filter( is_array( $patterns ) ? $patterns : array(), static fn( $p ) => is_string( $p ) && '' !== trim( $p ) ) ); } /** * Whether a value contains a well-known secret format: payment and webhook keys, PEM/PGP * private keys, cloud and chat tokens, JWTs. Shared by the Autoloaded options analyzer and * Database clean-up samples. * * @since 1.0.0 * @param string $raw Raw value. * @return bool */ public static function looks_like_secret( string $raw ): bool { $formats = array( '\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{8,}', // Stripe keys. '\bwhsec_[A-Za-z0-9]{8,}', // Stripe webhook secrets. '-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----', // PEM and PGP private keys. '\bAKIA[0-9A-Z]{16}\b', // AWS access keys. '\bgh[pousr]_[A-Za-z0-9]{20,}', // GitHub tokens. '\bxox[abprs]-[A-Za-z0-9-]{10,}', // Slack tokens. '\bAIza[0-9A-Za-z_\-]{30,}', // Google API keys. '\beyJ[A-Za-z0-9_\-]{5,}\.eyJ[A-Za-z0-9_\-]{5,}', // JWTs. ); return (bool) preg_match( '/' . implode( '|', $formats ) . '/', $raw ); } /** * Whether the current user may reveal masked values. * * @since 1.0.0 * @return bool */ public static function can_reveal(): bool { $cap = self::setting( 'reveal' ); $cap = null === $cap ? 'manage_options' : (string) $cap; return 'none' !== $cap && '' !== $cap && current_user_can( $cap ); } /** * Patterns the current user may not see: empty when they can reveal masked values. * * @since 1.0.0 * @return string[] */ public static function masked_for_current_user(): array { return self::can_reveal() ? array() : self::patterns(); } /** * Whether a key matches any pattern: whole key, "*" = anything, ignoring case and accents. * * @since 1.0.0 * @param string $key Meta key. * @param string[]|null $patterns Patterns; null = the saved sensitive patterns. * @return bool */ public static function matches( string $key, ?array $patterns = null ): bool { $patterns = null === $patterns ? self::patterns() : $patterns; if ( ! $patterns ) { return false; } $key = self::fold( $key ); foreach ( $patterns as $pattern ) { $regex = '/^' . str_replace( '\*', '.*', preg_quote( self::fold( (string) $pattern ), '/' ) ) . '$/is'; if ( preg_match( $regex, $key ) ) { return true; } } return false; } /** * SQL clauses that leave out rows whose stored key matches a pattern. * * Runs on the stored key in the database, so it holds whatever was typed: LIKE uses the * column's collation, the same one that decides `meta_key = %s` matches. LIKE compares * character by character, so zero-width characters, "ß" and ligatures (which `=` ignores or * expands) are replaced in the key first. * * Assumes a non-binary text column (VARCHAR/TEXT of any charset); a VARBINARY column would * defeat the plain-ASCII check. * * @since 1.0.0 * @param string $column Column, e.g. "meta_key" or "im.meta_key". A * literal from our code, never user input. * @param string[]|null $patterns Patterns; null = those masked for the current user. * @param array{shown?: string[], hidden?: string[]} $checked Exact stored keys already checked * (checked_keys()): "shown" rows skip the per-row * check, "hidden" rows are always left out. * @return array{0: string, 1: string[]} " AND …" clauses with %s placeholders ('' for none), * and their args, for $wpdb->prepare(). */ public static function sql_exclusions( string $column, ?array $patterns = null, array $checked = array() ): array { global $wpdb; $patterns = null === $patterns ? self::masked_for_current_user() : $patterns; if ( ! $patterns ) { return array( '', array() ); } // The REPLACE() chain runs on the key as utf8mb4: its characters don't exist in latin1 or // utf8mb3, and converting them to the column's charset would fail. $expr = "CONVERT( {$column} USING utf8mb4 )"; $map_args = array(); foreach ( self::SQL_FOLD as $from => $to ) { $expr = "REPLACE( {$expr}, %s, %s )"; $map_args[] = $from; $map_args[] = $to; } $likes = array(); foreach ( $patterns as $pattern ) { $likes[] = implode( '%', array_map( array( $wpdb, 'esc_like' ), explode( '*', self::fold( (string) $pattern ) ) ) ); } // Plain ASCII keys (nearly all of them) can't hold anything SQL_FOLD replaces or the // collation folds beyond case, so they skip the REPLACE() chain and compare lowercased // bytes (a binary LIKE, much cheaper than a collation-aware one). That chain ran for every // pattern on every matching row and made searches by people who can't reveal masked // values several times slower. A key is plain ASCII when its UTF-8 form has one byte per // character (converted first, so a latin1 column's "ß" or "é" still takes the full path). // A pattern that isn't plain ASCII after folding can't match a plain ASCII key. $ascii = "LENGTH( CONVERT( {$column} USING utf8mb4 ) ) = CHAR_LENGTH( {$column} )"; $fast = ''; $fast_args = array(); $slow = ''; $slow_args = array(); // Case is folded under a fixed charset or collation too: Turkish and Azeri collations lower // "I" to a dotless "ı", which would let "_API_KEY" past "*api_key*". The slow path checks // the folded key under utf8mb4's default collation and utf8mb4_unicode_ci (exists on every // supported MySQL and MariaDB), and the raw key under the column's own collation, so no // one collation's folding can be used to slip past the others. foreach ( $likes as $like ) { if ( ! preg_match( '/[^\x00-\x7F]/', $like ) ) { $fast .= " AND CAST( LOWER( CONVERT( {$column} USING ascii ) ) AS BINARY ) NOT LIKE %s"; $fast_args[] = $like; // Raw key under the column's collation: ASCII patterns only (a latin1 column can't // be compared with characters it can't hold: "Illegal mix of collations"). $slow .= " AND LOWER( {$column} ) NOT LIKE %s"; $slow_args[] = $like; } $slow .= " AND LOWER( {$expr} ) NOT LIKE %s AND LOWER( {$expr} COLLATE utf8mb4_unicode_ci ) NOT LIKE %s"; $slow_args = array_merge( $slow_args, $map_args, array( $like ), $map_args, array( $like ) ); } $sql = "( {$ascii}{$fast} ) OR ( NOT ( {$ascii} ){$slow} )"; $args = array_merge( $fast_args, $slow_args ); // Keys checked once already, compared byte for byte (a binary IN never matches a // look-alike key): shown ones skip the per-row check, which otherwise runs for every // matching row; hidden ones (PHP folds more than LIKE, e.g. combining accents) stay out. $shown = array_values( array_filter( (array) ( $checked['shown'] ?? array() ), 'is_string' ) ); $hidden = array_values( array_filter( (array) ( $checked['hidden'] ?? array() ), 'is_string' ) ); if ( $shown ) { $sql = "CAST( {$column} AS BINARY ) IN ( " . implode( ', ', array_fill( 0, count( $shown ), '%s' ) ) . " ) OR {$sql}"; $args = array_merge( $shown, $args ); } $sql = " AND ( {$sql} )"; if ( $hidden ) { $sql = " AND CAST( {$column} AS BINARY ) NOT IN ( " . implode( ', ', array_fill( 0, count( $hidden ), '%s' ) ) . ' )' . $sql; $args = array_merge( $hidden, $args ); } return array( $sql, $args ); } /** * Stored order meta keys already checked against these patterns, for sql_exclusions(). * * Reads the lists built in the background; when they are missing, older than a day or built * for other patterns, it queues a rebuild and answers with what is still valid. A key's * result never changes for the same patterns and rule, so an old list is only incomplete * (new keys get the per-row check), never wrong. * * @since 1.0.0 * @param string[] $patterns Patterns. * @return array{shown: string[], hidden: string[]} */ public static function checked_keys( array $patterns ): array { $none = array( 'shown' => array(), 'hidden' => array(), ); if ( ! $patterns ) { return $none; } $saved = get_option( self::KEYS_OPTION ); $valid = is_array( $saved ) && ( $saved['hash'] ?? '' ) === self::rule_hash( $patterns ) && is_array( $saved['shown'] ?? null ) && is_array( $saved['hidden'] ?? null ); if ( ! $valid || (int) ( $saved['time'] ?? 0 ) < time() - DAY_IN_SECONDS ) { self::schedule_refresh(); } if ( ! $valid ) { return $none; } return array( 'shown' => array_values( array_filter( $saved['shown'], 'is_string' ) ), 'hidden' => array_values( array_filter( $saved['hidden'], 'is_string' ) ), ); } /** * Rebuild the checked-keys lists (background job): the most used distinct stored keys (byte * for byte, so case and accent twins are each checked), checked in SQL with the same rule as * sql_exclusions() and in PHP with matches(). A key is shown only when both let it through. * * @since 1.0.0 * @return void */ public static function refresh_checked_keys(): void { global $wpdb; $patterns = self::patterns(); if ( ! $patterns ) { delete_option( self::KEYS_OPTION ); return; } list( $rule_sql, $rule_args ) = self::sql_exclusions( 'd.k', $patterns ); $sql = 'SELECT d.k, ( 1=1' . $rule_sql . ' ) AS ok FROM ( SELECT MIN( meta_key ) AS k, COUNT(*) AS n FROM %i GROUP BY CAST( meta_key AS BINARY ) ORDER BY n DESC LIMIT %d ) d WHERE d.k IS NOT NULL'; $args = array_merge( $rule_args, array( OrderUtil::get_table_for_order_meta(), self::KEYS_MAX ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- a background job, once a day at most; $rule_sql from sql_exclusions() is literal SQL on the fixed column d.k with %s placeholders only; every value is in $args, prepared here. $rows = (array) $wpdb->get_results( $wpdb->prepare( $sql, $args ) ); $shown = array(); $hidden = array(); foreach ( $rows as $row ) { $key = (string) $row->k; if ( '1' === (string) $row->ok && ! self::matches( $key, $patterns ) ) { $shown[] = $key; } else { $hidden[] = $key; } } update_option( self::KEYS_OPTION, array( 'hash' => self::rule_hash( $patterns ), 'time' => time(), 'shown' => $shown, 'hidden' => $hidden, ), false ); } /** * Queue a rebuild of the checked-keys lists, once, when a handler is registered. * * @since 1.0.0 * @return void */ private static function schedule_refresh(): void { // Only while something runs the job (Advanced order search registers it while on). if ( ! has_action( self::KEYS_JOB ) || ! function_exists( 'as_enqueue_async_action' ) || ! function_exists( 'as_has_scheduled_action' ) ) { return; } if ( ! as_has_scheduled_action( self::KEYS_JOB, array(), Jobs::GROUP ) ) { as_enqueue_async_action( self::KEYS_JOB, array(), Jobs::GROUP ); } } /** * Hash of the patterns and the rule version the checked-keys lists were built for. * * @param string[] $patterns Patterns. * @return string */ private static function rule_hash( array $patterns ): string { $patterns = array_values( array_map( 'strval', $patterns ) ); sort( $patterns ); return md5( (string) wp_json_encode( array( self::RULE_VERSION, $patterns ) ) ); } /** * A key folded the way a case- and accent-insensitive collation sees it ("Tokén" → "token", * "TOKEN" → "token", "fi" → "fi", "ß" → "ss", zero-width characters dropped). * * @since 1.0.0 * @param string $text Text. * @return string */ public static function fold( string $text ): string { if ( class_exists( 'Normalizer' ) ) { // Compatibility forms: full-width letters, ligatures, super/subscripts → plain ones. $normalized = \Normalizer::normalize( $text, \Normalizer::FORM_KC ); $text = is_string( $normalized ) ? $normalized : $text; } // Without intl (and as a safety net): full-width ASCII (U+FF01–U+FF5E) → ASCII. On invalid // UTF-8 the regex returns null and the text is kept as it is. $text = preg_replace_callback( '/[\x{FF01}-\x{FF5E}]/u', static fn( $m ) => chr( (int) mb_ord( $m[0], 'UTF-8' ) - 0xFEE0 ), $text ) ?? $text; // Characters a collation ignores (zero-width, BOM, soft hyphen) or expands (ß → ss, ligatures). $text = strtr( $text, self::SQL_FOLD ); // Marks and invisible characters it also ignores (combining accents with no precomposed // form, combining grapheme joiner, variation selectors, invisible operators). Accented // letters were composed above and are folded by remove_accents() below. $text = preg_replace( '/[\p{Mn}\p{Me}\p{Cf}]/u', '', $text ) ?? $text; // Fixed locale: German/Danish/Norwegian locales turn "ö" into "oe" and "å" into "aa", but // the collation folds them to "o" and "a" whatever the site language. Also maps ſ → s. return strtolower( remove_accents( $text, 'en_US' ) ); } /** * A saved Order meta setting, or null when none is saved and the power-up isn't registered. * * @since 1.0.0 * @param string $key Setting ID. * @return mixed */ private static function setting( string $key ) { $module = Plugin::instance()->registry()->get( self::MODULE ); return $module ? $module->setting( $key ) : null; } }