400 ] ); } if ( strlen( $term ) > 500 ) { return new \WP_Error( 'gemsecurity_search_long', __( 'That search is too long.', 'gemsecurity' ), [ 'status' => 400 ] ); } $regex = ! empty( $opts['regex'] ); $scope = ! empty( $opts['scope'] ) && is_array( $opts['scope'] ) ? $opts['scope'] : [ 'files', 'database' ]; // Build (and validate) the PCRE pattern used for file scanning. $pattern = $regex ? '/' . str_replace( '/', '\\/', $term ) . '/i' : '/' . preg_quote( $term, '/' ) . '/i'; if ( false === @preg_match( $pattern, '' ) ) { return new \WP_Error( 'gemsecurity_search_regex', __( 'That regular expression is not valid.', 'gemsecurity' ), [ 'status' => 400 ] ); } // Scanning several large tables and all plugin/theme/upload files // can take minutes on big sites. @set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long scan; bounded, not unlimited. $files = in_array( 'files', $scope, true ) ? self::search_files( $pattern ) : [ 'matches' => [], 'files_scanned' => 0, 'truncated' => false ]; $database = in_array( 'database', $scope, true ) ? self::search_database( $term, $regex ) : [ 'matches' => [], 'truncated' => false ]; return [ 'term' => $term, 'regex' => $regex, 'scope' => $scope, 'files_scanned' => $files['files_scanned'], 'file_matches' => $files['matches'], 'db_matches' => $database['matches'], 'file_count' => count( $files['matches'] ), 'db_count' => count( $database['matches'] ), 'truncated' => $files['truncated'] || $database['truncated'], ]; } /* --------------------------------------------------------------------- */ /* File search */ /* --------------------------------------------------------------------- */ /** Dependency / VCS / build directories that are never worth searching. */ private static function skip_dirs() { return [ 'node_modules', '.git', '.svn', '.hg', 'bower_components', '.cache' ]; } /** * @param string $pattern Validated PCRE pattern. * @return array { matches: array, files_scanned: int, truncated: bool } */ private static function search_files( $pattern ) { $exts = array_flip( self::text_extensions() ); $matches = []; $scanned = 0; $trunc = false; // Loose files at the install root (wp-config.php, index.php, .htaccess, // .user.ini…) and directly in wp-content (drop-ins) — tiny, and a // favourite hiding spot. $abs = untrailingslashit( \GemSecurity\Paths::root() ); $loose = array_merge( (array) glob( $abs . '/*' ), (array) glob( $abs . '/.[!.]*' ), (array) glob( \GemSecurity\Paths::content() . '/*' ), (array) glob( \GemSecurity\Paths::content() . '/.[!.]*' ) ); foreach ( array_unique( $loose ) as $path ) { if ( is_file( $path ) ) { self::scan_file( $path, $pattern, $exts, $matches, $scanned ); } } // High-signal, bounded locations first — mu-plugins, themes and core // (~3k files) — plus every stray folder at the root or in wp-content, // so they are always fully covered even when plugins and uploads are // big enough to hit the cap. Roots already walked are // pruned from later walks, and the catch-all wp-content/root walks also // skip whatever is still queued, so nothing is scanned twice. $roots = [ \GemSecurity\Paths::mu(), get_stylesheet_directory(), get_template_directory(), get_theme_root(), $abs . '/wp-admin', \GemSecurity\Paths::includes(), \GemSecurity\Paths::content(), $abs, \GemSecurity\Paths::plugins(), \GemSecurity\Paths::uploads(), ]; $roots = array_values( array_unique( array_filter( array_map( 'untrailingslashit', $roots ), 'is_dir' ) ) ); foreach ( $roots as $i => $root ) { $prune = array_merge( array_slice( $roots, 0, $i ), array_slice( $roots, $i + 1 ) ); if ( self::walk_root( $root, $pattern, $exts, $matches, $scanned, $prune ) ) { $trunc = true; } if ( $scanned >= self::MAX_FILES || count( $matches ) >= self::MAX_MATCHES ) { $trunc = true; break; } } return [ 'matches' => $matches, 'files_scanned' => $scanned, 'truncated' => $trunc ]; } /** * Recursively search one directory tree (pruning dependency/VCS dirs), * sharing the overall file and match budget. * * @param string $root * @param string $pattern * @param array $exts * @param array $matches (by reference) * @param int $scanned (by reference) * @param array $prune Absolute directories (already walked, or walked later) to skip. * @return bool Whether the budget was hit while walking this root. */ private static function walk_root( $root, $pattern, array $exts, array &$matches, &$scanned, array $prune = [] ) { $skip = self::skip_dirs(); try { $dir = new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ); $filter = new \RecursiveCallbackFilterIterator( $dir, static function ( $current ) use ( $skip, $prune ) { if ( ! $current->isDir() ) { return true; } return ! in_array( $current->getFilename(), $skip, true ) && ! in_array( untrailingslashit( $current->getPathname() ), $prune, true ); } ); $it = new \RecursiveIteratorIterator( $filter ); } catch ( \Exception $e ) { return false; } foreach ( $it as $file ) { if ( $scanned >= self::MAX_FILES || count( $matches ) >= self::MAX_MATCHES ) { return true; } if ( $file->isFile() ) { self::scan_file( $file->getPathname(), $pattern, $exts, $matches, $scanned ); } } return false; } /** * Search a single file, appending any line matches (up to MAX_PER_FILE). * * @param string $path * @param string $pattern * @param array $exts * @param array $matches (by reference) * @param int $scanned (by reference) */ private static function scan_file( $path, $pattern, array $exts, array &$matches, &$scanned ) { $name = basename( $path ); $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); if ( '' === $ext && '.htaccess' === $name ) { $ext = 'htaccess'; } if ( ! isset( $exts[ $ext ] ) ) { return; } if ( ! is_file( $path ) || filesize( $path ) > self::MAX_FILE_BYTES ) { return; } $contents = @file_get_contents( $path ); if ( false === $contents || '' === $contents ) { return; } $scanned++; if ( false !== strpos( $contents, "\0" ) ) { return; // binary. } if ( ! preg_match( $pattern, $contents ) ) { return; } $rel = \GemSecurity\Paths::relative( $path ); $lines = preg_split( '/\r\n|\r|\n/', $contents ); $per_file = 0; foreach ( $lines as $i => $line ) { if ( preg_match( $pattern, $line ) ) { $matches[] = [ 'file' => $rel, 'line' => $i + 1, 'snippet' => self::snippet_line( $line ), ]; if ( ++$per_file >= self::MAX_PER_FILE || count( $matches ) >= self::MAX_MATCHES ) { break; } } } } /* --------------------------------------------------------------------- */ /* Database search */ /* --------------------------------------------------------------------- */ /** * Search the highest-signal content tables. Literal terms use LIKE; regex * terms use MySQL REGEXP. * * @param string $term * @param bool $regex * @return array { matches: array, truncated: bool } */ private static function search_database( $term, $regex ) { global $wpdb; $matches = []; $trunc = false; $op = $regex ? 'REGEXP' : 'LIKE'; $val = $regex ? $term : '%' . $wpdb->esc_like( $term ) . '%'; $queries = [ [ 'label' => 'wp_options', 'sql' => "SELECT option_id AS id, option_name AS ref, option_value AS val FROM {$wpdb->options} WHERE option_value {$op} %s LIMIT %d", 'source' => 'wp_options', ], [ 'label' => 'wp_posts', 'sql' => "SELECT ID AS id, post_title AS ref, post_content AS val FROM {$wpdb->posts} WHERE post_content {$op} %s OR post_title {$op} %s OR post_excerpt {$op} %s LIMIT %d", 'source' => 'wp_posts', ], [ 'label' => 'wp_postmeta', 'sql' => "SELECT meta_id AS id, meta_key AS ref, meta_value AS val FROM {$wpdb->postmeta} WHERE meta_value {$op} %s LIMIT %d", 'source' => 'wp_postmeta', ], [ 'label' => 'wp_usermeta', 'sql' => "SELECT umeta_id AS id, meta_key AS ref, meta_value AS val FROM {$wpdb->usermeta} WHERE meta_value {$op} %s LIMIT %d", 'source' => 'wp_usermeta', ], [ 'label' => 'wp_termmeta', 'sql' => "SELECT meta_id AS id, meta_key AS ref, meta_value AS val FROM {$wpdb->termmeta} WHERE meta_value {$op} %s LIMIT %d", 'source' => 'wp_termmeta', ], [ 'label' => 'wp_term_taxonomy', 'sql' => "SELECT term_taxonomy_id AS id, taxonomy AS ref, description AS val FROM {$wpdb->term_taxonomy} WHERE description {$op} %s LIMIT %d", 'source' => 'wp_term_taxonomy', ], [ 'label' => 'wp_comments', 'sql' => "SELECT comment_ID AS id, comment_author AS ref, comment_content AS val FROM {$wpdb->comments} WHERE comment_content {$op} %s LIMIT %d", 'source' => 'wp_comments', ], ]; foreach ( $queries as $q ) { $args = array_fill( 0, substr_count( $q['sql'], '%s' ), $val ); $args[] = self::MAX_DB_PER_TABLE; // $q['sql'] is one of the fixed templates defined above; only its // %s / %d placeholders carry the admin-supplied search value, and // $wpdb->prepare() binds them. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter $rows = $wpdb->get_results( $wpdb->prepare( $q['sql'], ...$args ), ARRAY_A ); if ( count( (array) $rows ) >= self::MAX_DB_PER_TABLE ) { $trunc = true; } foreach ( (array) $rows as $r ) { $matches[] = [ 'source' => $q['source'], 'id' => (int) $r['id'], 'ref' => (string) $r['ref'], 'snippet' => self::snippet_around( (string) $r['val'], $term, $regex ), ]; if ( count( $matches ) >= self::MAX_MATCHES ) { return [ 'matches' => $matches, 'truncated' => true ]; } } } return [ 'matches' => $matches, 'truncated' => $trunc ]; } /* --------------------------------------------------------------------- */ /* Snippet helpers */ /* --------------------------------------------------------------------- */ /** * Trim a matched line for display. * * @param string $line * @return string */ private static function snippet_line( $line ) { $line = trim( $line ); if ( strlen( $line ) <= self::SNIPPET_LEN ) { return $line; } return substr( $line, 0, self::SNIPPET_LEN ) . '…'; } /** * Extract a window of text around the first match inside a value. * * @param string $value * @param string $term * @param bool $regex * @return string */ private static function snippet_around( $value, $term, $regex ) { $value = (string) $value; $pos = false; if ( ! $regex ) { $pos = stripos( $value, $term ); } if ( false === $pos ) { // Regex, or literal not found in the raw value (serialized/escaped) — // fall back to the head of the value. return self::snippet_line( $value ); } $start = max( 0, $pos - 60 ); $snippet = substr( $value, $start, self::SNIPPET_LEN ); return ( $start > 0 ? '…' : '' ) . trim( $snippet ) . ( strlen( $value ) > $start + self::SNIPPET_LEN ? '…' : '' ); } }