,meta_keys:string[]} */ public function run() { $started = microtime( true ); $state = array( 'files' => 0, 'bytes' => 0, 'complete' => true, 'stop' => false, 'started' => $started, ); $sources = array(); foreach ( $this->source_units() as $unit ) { if ( $state['stop'] ) { break; } $found = array( 'meta_keys' => array(), 'strings' => array(), 'hooks' => array(), 'handler' => false, 'mail' => false, 'files' => array(), ); foreach ( $this->php_files( $unit['paths'], $state ) as $file ) { $info = self::analyse_file( $file ); if ( ! $info['suspicious'] ) { continue; } $found['meta_keys'] = array_merge( $found['meta_keys'], $info['meta_keys'] ); $found['strings'] = array_merge( $found['strings'], $info['strings'] ); $found['hooks'] = array_merge( $found['hooks'], $info['hooks'] ); $found['handler'] = $found['handler'] || $info['handler']; $found['mail'] = $found['mail'] || $info['mail']; if ( count( $found['files'] ) < 5 ) { $found['files'][] = $this->relative_path( $file ); } } if ( empty( $found['files'] ) ) { continue; } $sources[ $unit['key'] ] = array( 'key' => $unit['key'], 'label' => $unit['label'], 'type' => $unit['type'], 'meta_keys' => array_values( array_unique( $found['meta_keys'] ) ), 'strings' => array_slice( array_values( array_unique( $found['strings'] ) ), 0, 4 ), 'hooks' => array_values( array_unique( $found['hooks'] ) ), 'handler' => $found['handler'], 'mail' => $found['mail'], 'files' => $found['files'], ); if ( count( $sources ) >= self::MAX_SOURCES ) { break; } } $result = array( 'time' => time(), 'complete' => $state['complete'], 'files' => $state['files'], 'bytes' => $state['bytes'], 'elapsed' => round( microtime( true ) - $started, 2 ), 'sources' => $sources, 'meta_keys' => $this->discover_meta_keys(), ); set_transient( self::RESULT_KEY, $result, self::RESULT_TTL ); return $result; } /** * The code units to read: one per active plugin, one per mu-plugin file or * folder, one for the active theme (and its parent). * * @return array */ private function source_units() { $units = array(); $plugins = array(); $skip = self::skip_plugin_slugs(); $plug_dir = wp_normalize_path( WP_PLUGIN_DIR ); $mains = wp_get_active_and_valid_plugins(); if ( is_multisite() && function_exists( 'wp_get_active_network_plugins' ) ) { $mains = array_merge( $mains, wp_get_active_network_plugins() ); } foreach ( array_unique( $mains ) as $main ) { $main = wp_normalize_path( $main ); $rel = ltrim( substr( $main, strlen( $plug_dir ) ), '/' ); $slug = strtok( $rel, '/' ); if ( false === $slug || in_array( $slug, $skip, true ) ) { continue; } $is_folder = false !== strpos( $rel, '/' ); $plugins[] = array( 'key' => 'plugin:' . $slug, 'label' => WPCEV_Conflicts::plugin_label( $slug ), 'type' => 'plugin', 'paths' => array( $is_folder ? $plug_dir . '/' . $slug : $main ), ); } if ( defined( 'WPMU_PLUGIN_DIR' ) && is_dir( WPMU_PLUGIN_DIR ) ) { $mu_dir = wp_normalize_path( WPMU_PLUGIN_DIR ); foreach ( (array) glob( $mu_dir . '/*' ) as $entry ) { $entry = wp_normalize_path( $entry ); if ( is_dir( $entry ) ) { $units[] = array( 'key' => 'mu:' . basename( $entry ), 'label' => basename( $entry ) . ' (mu-plugin)', 'type' => 'mu-plugin', 'paths' => array( $entry ), ); } elseif ( '.php' === substr( $entry, -4 ) ) { $units[] = array( 'key' => 'mu:' . basename( $entry ), 'label' => basename( $entry ) . ' (mu-plugin)', 'type' => 'mu-plugin', 'paths' => array( $entry ), ); } } } $theme_paths = array( get_stylesheet_directory() ); if ( get_template_directory() !== get_stylesheet_directory() ) { $theme_paths[] = get_template_directory(); } $units[] = array( 'key' => 'theme:' . get_stylesheet(), 'label' => sprintf( /* translators: %s: theme folder name */ __( 'theme %s', 'wpcoretools-email-verify' ), get_stylesheet() ), 'type' => 'theme', 'paths' => $theme_paths, ); // Small, site-specific code first (mu-plugins, theme), then plugins, so the // file and time caps can only ever cut off the large third-party tail. return array_merge( $units, $plugins ); } /** * PHP files under the given paths, honouring the skip list and the global * file / byte / time caps (the state is shared across all units). * * @param string[] $paths * @param array $state Counters, updated by reference. * @return string[] */ private function php_files( $paths, &$state ) { $out = array(); $skip = self::skip_dirs(); foreach ( $paths as $path ) { if ( is_file( $path ) ) { $list = array( $path ); } elseif ( is_dir( $path ) ) { $list = array(); try { $dir = new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS ); $filtered = new RecursiveCallbackFilterIterator( $dir, function ( $current ) use ( $skip ) { if ( $current->isDir() ) { return ! in_array( strtolower( $current->getFilename() ), $skip, true ); } return 'php' === strtolower( $current->getExtension() ); } ); foreach ( new RecursiveIteratorIterator( $filtered ) as $file ) { $list[] = $file->getPathname(); if ( count( $list ) > self::MAX_UNIT_FILES ) { // One huge plugin must not use up the budget of the rest. $state['complete'] = false; break; } } } catch ( \Exception $e ) { unset( $e ); } } else { continue; } foreach ( $list as $file ) { $size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a vanished file is simply skipped. if ( $size <= 0 || $size > self::MAX_FILE_BYTES ) { continue; } if ( $state['files'] >= self::MAX_FILES || ( $state['bytes'] + $size ) > self::MAX_TOTAL_BYTES || ( microtime( true ) - $state['started'] ) > self::TIME_BUDGET ) { // Total budget used up: stop everything, keep what was read. $state['complete'] = false; $state['stop'] = true; return $out; } ++$state['files']; $state['bytes'] += $size; $out[] = $file; } } return $out; } private function relative_path( $file ) { $file = wp_normalize_path( $file ); $root = wp_normalize_path( WP_CONTENT_DIR ); return 0 === strpos( $file, $root . '/' ) ? substr( $file, strlen( $root ) + 1 ) : basename( $file ); } /* ─── One file ───────────────────────────────────────────────────────── */ /** * Look for the signs of a home-grown email verification in one PHP file. * * "Suspicious" needs two independent signs so a plugin that merely mentions * the word "verify" is not flagged: a verification-looking user meta key * together with a hook on registration/login/checkout, a login handler or a * "verify your email" text; or that text together with such a hook and a * mail being sent. * * @param string $file Absolute path. * @return array{suspicious:bool,high:bool,meta_keys:string[],strings:string[],hooks:string[],handler:bool,mail:bool} */ public static function analyse_file( $file ) { static $cache = array(); if ( isset( $cache[ $file ] ) ) { return $cache[ $file ]; } $info = array( 'suspicious' => false, 'high' => false, 'meta_keys' => array(), 'strings' => array(), 'hooks' => array(), 'handler' => false, 'mail' => false, ); $size = is_readable( $file ) ? (int) filesize( $file ) : 0; if ( $size <= 0 || $size > self::MAX_FILE_BYTES ) { $cache[ $file ] = $info; return $info; } $code = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin source, not a remote URL. if ( false === $code || ( false === stripos( $code, 'verif' ) && false === stripos( $code, 'confirm' ) ) ) { $cache[ $file ] = $info; return $info; } $keys = array(); // get/update/add/delete_user_meta( $id, 'key', ... ). if ( preg_match_all( '/(?:get|update|add|delete)_user_meta\s*\(\s*[^,]+,\s*([\'"])([^\'"]+)\1/i', $code, $m ) ) { $keys = array_merge( $keys, $m[2] ); } // Keys held in a constant: define( 'X', 'key' ) / const X = 'key'. if ( preg_match_all( '/(?:define\s*\(\s*[\'"][A-Za-z0-9_]+[\'"]\s*,\s*|const\s+[A-Za-z0-9_]+\s*=\s*)([\'"])([A-Za-z0-9_\-]+)\1/', $code, $m ) ) { $keys = array_merge( $keys, $m[2] ); } foreach ( array_unique( $keys ) as $key ) { if ( preg_match( self::META_KEY_REGEX, $key ) && 0 !== strpos( $key, 'wpcev_' ) && 0 !== strpos( $key, '_wc_email_verif' ) ) { $info['meta_keys'][] = $key; } } if ( preg_match_all( '/verify\s+your\s+e-?mail|confirm\s+your\s+e-?mail|verify\s+your\s+account|e-?mail\s+verification|e-?mail\s+address\s+is\s+not\s+(?:yet\s+)?(?:verified|confirmed)/i', $code, $m ) ) { foreach ( $m[0] as $phrase ) { $info['strings'][] = strtolower( preg_replace( '/\s+/', ' ', $phrase ) ); } $info['strings'] = array_values( array_unique( $info['strings'] ) ); } if ( preg_match_all( '/add_(?:action|filter)\s*\(\s*[\'"](' . implode( '|', array_map( 'preg_quote', self::watched_hooks() ) ) . ')[\'"]/i', $code, $m ) ) { $info['hooks'] = array_values( array_unique( array_map( 'strtolower', $m[1] ) ) ); } $info['mail'] = (bool) preg_match( '/\bwp_mail\s*\(|->mailer\s*\(/', $code ); $info['handler'] = ! empty( $info['meta_keys'] ) && (bool) preg_match( '/\bwp_signon\s*\(|\bwp_authenticate\s*\(/', $code ); $has_meta = ! empty( $info['meta_keys'] ); $has_text = ! empty( $info['strings'] ); $has_hook = ! empty( $info['hooks'] ); $info['high'] = $info['handler'] || ( $has_meta && $has_hook ); $info['suspicious'] = ( $has_meta && ( $has_text || $has_hook || $info['handler'] ) ) || ( $has_text && $has_hook && $info['mail'] ); $cache[ $file ] = $info; return $info; } /** * Hooks through which a second verifier would take part in registration, * login or checkout. * * @return string[] */ public static function watched_hooks() { return array( 'user_register', 'wp_authenticate_user', 'authenticate', 'woocommerce_process_login_errors', 'woocommerce_registration_errors', 'woocommerce_registration_redirect', 'woocommerce_after_checkout_validation', 'woocommerce_store_api_checkout_order_processed', 'woocommerce_store_api_cart_errors', 'woocommerce_store_api_checkout_update_order_from_request', ); } /* ─── Data ───────────────────────────────────────────────────────────── */ /** * User meta keys in the database that look like a verification status, * excluding this plugin's and WooCommerce's own. One query; run as part of * the on-demand scan only. * * @return string[] */ private function discover_meta_keys() { global $wpdb; // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- one admin-triggered, LIMITed lookup of key names. $rows = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT meta_key FROM {$wpdb->usermeta} WHERE ( meta_key LIKE %s OR meta_key LIKE %s OR meta_key LIKE %s ) AND meta_key NOT LIKE %s AND meta_key NOT LIKE %s LIMIT %d", '%' . $wpdb->esc_like( 'verif' ) . '%', '%' . $wpdb->esc_like( 'email_confirm' ) . '%', '%' . $wpdb->esc_like( 'confirm_email' ) . '%', $wpdb->esc_like( 'wpcev_' ) . '%', $wpdb->esc_like( '_wc_email_verif' ) . '%', self::MAX_META_KEYS ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery return is_array( $rows ) ? array_values( array_map( 'strval', $rows ) ) : array(); } /** * Does a meta key hold a yes/no status (as opposed to a token or a time)? * And which value means "not verified"? * * @param string $key * @return string|null 'truthy' when a set value means unverified (pending flags), * 'falsy' when an empty/0 value means unverified (verified flags), * null for keys that are not a status. */ public static function key_meaning( $key ) { if ( preg_match( '/token|hash|code|_key$|sent|_at$|_time|date|expires|attempt|count/i', $key ) ) { return null; } if ( preg_match( '/pending|unverified/i', $key ) ) { return 'truthy'; } if ( preg_match( '/verif|confirm|activ/i', $key ) ) { return 'falsy'; } return null; } /** * Users another tool marks unverified, per meta key: ONE grouped query over * the given keys, capped. * * @param string[] $keys * @return array meta key => number of users counted as unverified. */ public static function count_unverified( $keys ) { global $wpdb; $status_keys = array(); foreach ( array_slice( array_unique( array_filter( $keys ) ), 0, self::MAX_META_KEYS ) as $key ) { if ( null !== self::key_meaning( $key ) ) { $status_keys[] = $key; } } if ( empty( $status_keys ) ) { return array(); } $placeholders = implode( ', ', array_fill( 0, count( $status_keys ), '%s' ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery -- the IN() list is built from %s placeholders; a single grouped, key-bounded count. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, CASE WHEN meta_value IN ('', '0', 'no', 'false', 'off') THEN 0 ELSE 1 END AS truthy, COUNT(DISTINCT user_id) AS users FROM {$wpdb->usermeta} WHERE meta_key IN ( $placeholders ) GROUP BY meta_key, truthy", $status_keys ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery $out = array(); foreach ( (array) $rows as $row ) { $meaning = self::key_meaning( $row->meta_key ); $is_set = 1 === (int) $row->truthy; if ( ( 'truthy' === $meaning && $is_set ) || ( 'falsy' === $meaning && ! $is_set ) ) { $out[ $row->meta_key ] = ( isset( $out[ $row->meta_key ] ) ? $out[ $row->meta_key ] : 0 ) + (int) $row->users; } } return $out; } }