max_files = isset( $args['max_files'] ) ? (int) $args['max_files'] : 20000; $this->max_depth = isset( $args['max_depth'] ) ? (int) $args['max_depth'] : 12; $this->max_read_bytes = isset( $args['max_read_bytes'] ) ? (int) $args['max_read_bytes'] : 1024; } /* -------------------------------------------------------------------- * Traversal * ----------------------------------------------------------------- */ /** * Walk a directory and return absolute file paths. * * @param string $root Absolute directory path. * @param bool $recursive Descend into subdirectories. * @return string[] */ public function walk( $root, $recursive = true ) { $this->truncated = false; $this->unreadable = 0; $this->seen = 0; $root = $this->realpath( $root ); if ( ! $root || ! is_dir( $root ) ) { return array(); } // Refuse to walk anything outside the WordPress install. if ( ! $this->is_within_abspath( $root ) ) { return array(); } return $recursive ? $this->walk_recursive( $root ) : $this->walk_flat( $root ); } protected function walk_flat( $root ) { $out = array(); $handle = @opendir( $root ); if ( false === $handle ) { $this->unreadable++; return $out; } while ( false !== ( $entry = readdir( $handle ) ) ) { if ( '.' === $entry || '..' === $entry ) { continue; } $path = $root . DIRECTORY_SEPARATOR . $entry; if ( ! is_file( $path ) ) { continue; } if ( $this->seen >= $this->max_files ) { $this->truncated = true; break; } $this->seen++; $out[] = $path; } closedir( $handle ); sort( $out ); return $out; } protected function walk_recursive( $root ) { $out = array(); try { $dir_iter = new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS ); $iter = new RecursiveIteratorIterator( $dir_iter, RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD ); $iter->setMaxDepth( $this->max_depth ); foreach ( $iter as $file_info ) { if ( ! $file_info->isFile() ) { continue; } if ( $this->seen >= $this->max_files ) { $this->truncated = true; break; } if ( ! $file_info->isReadable() ) { $this->unreadable++; continue; } $this->seen++; $out[] = $file_info->getPathname(); } } catch ( Exception $e ) { $this->unreadable++; } sort( $out ); return $out; } /* -------------------------------------------------------------------- * Description / hashing * ----------------------------------------------------------------- */ /** * Metadata for a single file. Never includes contents. * * @param string $abs_path Absolute path. * @param bool $with_hash Compute md5. Skip on very large sets for speed. * @return array */ public function describe( $abs_path, $with_hash = true ) { $entry = array( 'path' => $this->relpath( $abs_path ), 'ext' => strtolower( pathinfo( $abs_path, PATHINFO_EXTENSION ) ), 'size' => null, 'modified_time' => null, 'md5' => null, ); $size = @filesize( $abs_path ); if ( false !== $size ) { $entry['size'] = (int) $size; } $mtime = @filemtime( $abs_path ); if ( false !== $mtime ) { $entry['modified_time'] = gmdate( 'c', $mtime ); } if ( $with_hash ) { $hash = @md5_file( $abs_path ); if ( false !== $hash ) { $entry['md5'] = $hash; } else { $this->unreadable++; } } return $entry; } /** * Reads a small file head through WordPress's filesystem abstraction. * The full buffer is never returned and is discarded after truncation. * Files above 1 MiB are skipped so signature sniffing remains bounded. * * @param string $abs_path Absolute path. * @return string|false */ protected function read_file_head( $abs_path ) { global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! WP_Filesystem() || ! $wp_filesystem || ! $wp_filesystem->exists( $abs_path ) ) { $this->unreadable++; return false; } $size = $wp_filesystem->size( $abs_path ); if ( false === $size || $size > 1048576 ) { $this->unreadable++; return false; } $contents = $wp_filesystem->get_contents( $abs_path ); if ( false === $contents ) { $this->unreadable++; return false; } $head = substr( $contents, 0, $this->max_read_bytes ); unset( $contents ); return $head; } /** * Signature sniff. Reads the first N bytes and returns a BOOLEAN verdict. * The bytes themselves are discarded and never returned to the caller. * * @param string $abs_path Absolute path. * @return bool True if a PHP open tag appears in the head of the file. */ public function head_contains_php_tag( $abs_path ) { $head = $this->read_file_head( $abs_path ); if ( false === $head ) { return false; } $found = ( false !== strpos( $head, ' Pattern name => found. */ public function signature_flags( $abs_path ) { $patterns = array( 'eval' => 'eval(', 'base64_decode' => 'base64_decode(', 'gzinflate' => 'gzinflate(', 'str_rot13' => 'str_rot13(', 'assert' => 'assert(', 'create_function' => 'create_function(', ); $flags = array_fill_keys( array_keys( $patterns ), false ); $head = $this->read_file_head( $abs_path ); if ( false === $head ) { return $flags; } foreach ( $patterns as $key => $needle ) { $flags[ $key ] = ( false !== strpos( $head, $needle ) ); } unset( $head ); // contents discarded, never surfaced return $flags; } /* -------------------------------------------------------------------- * Path handling * ----------------------------------------------------------------- */ /** * Convert an absolute path to one relative to ABSPATH. * Absolute paths are never returned to callers. * * @param string $abs_path Absolute path. * @return string */ public function relpath( $abs_path ) { $abs = wp_normalize_path( $abs_path ); $base = wp_normalize_path( ABSPATH ); if ( 0 === strpos( $abs, $base ) ) { return ltrim( substr( $abs, strlen( $base ) ), '/' ); } // Outside ABSPATH (e.g. WP_CONTENT_DIR moved). Return basename only // rather than leaking the server path. return '[outside-abspath]/' . basename( $abs ); } /** * @param string $abs_path Absolute path. * @return bool */ public function is_within_abspath( $abs_path ) { $abs = wp_normalize_path( $abs_path ); $base = wp_normalize_path( ABSPATH ); if ( 0 === strpos( $abs, $base ) ) { return true; } // WP_CONTENT_DIR may legitimately sit outside ABSPATH. if ( defined( 'WP_CONTENT_DIR' ) ) { $content = wp_normalize_path( WP_CONTENT_DIR ); if ( 0 === strpos( $abs, $content ) ) { return true; } } return false; } protected function realpath( $path ) { $real = @realpath( $path ); return false === $real ? false : $real; } /** * Confirms ABSPATH resolves and is readable. Surfaced in selftest output * so an odd hosting layout shows up immediately. * * @return bool */ public function abspath_sanity_check() { $base = $this->realpath( ABSPATH ); return (bool) ( $base && is_dir( $base ) && is_readable( $base ) ); } /* -------------------------------------------------------------------- * State * ----------------------------------------------------------------- */ public function was_truncated() { return $this->truncated; } public function unreadable_count() { return $this->unreadable; } public function files_seen() { return $this->seen; } }