$checksums Validated official checksums. * @return array */ public function inspect( string $wordpress_root, array $checksums ): array { $root = realpath( $wordpress_root ); if ( false === $root || ! is_dir( $root ) ) { throw new \InvalidArgumentException( 'wordpress_core_root_invalid' ); } $root = rtrim( wp_normalize_path( $root ), '/' ); $matched = 0; $missing = array(); $mismatch = array(); $missing_count = 0; $mismatch_count = 0; $skipped = 0; $considered = 0; ksort( $checksums ); foreach ( $checksums as $relative => $checksum ) { if ( 0 === strpos( $relative, 'wp-content/' ) ) { continue; } if ( ++$considered > self::MAX_FILES ) { ++$skipped; continue; } if ( ! $this->safe_relative_path( $relative ) || 1 !== preg_match( '/^[a-f0-9]{32}$/D', $checksum ) ) { ++$skipped; continue; } $path = $root . '/' . $relative; if ( is_link( $path ) ) { ++$skipped; continue; } if ( ! file_exists( $path ) ) { ++$missing_count; $this->append( $missing, $relative ); continue; } $real = realpath( $path ); if ( false === $real || 0 !== strpos( wp_normalize_path( $real ), $root . '/' ) || ! is_file( $real ) || ! is_readable( $real ) ) { ++$skipped; continue; } // Read-only hash of a validated path inside the configured WordPress root. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_md5_file $actual = md5_file( $real ); if ( false === $actual ) { ++$skipped; } elseif ( hash_equals( $checksum, strtolower( $actual ) ) ) { ++$matched; } else { ++$mismatch_count; $this->append( $mismatch, $relative ); } } $expected = array_fill_keys( array_keys( $checksums ), true ); $additional = $this->additional_protected_files( $root, $expected ); return array( 'matched' => $matched, 'missing' => $missing, 'mismatched' => $mismatch, 'missing_count' => $missing_count, 'mismatched_count' => $mismatch_count, 'additional_count' => $additional['count'], 'skipped_count' => $skipped + $additional['skipped'], 'considered_count' => min( $considered, self::MAX_FILES ), 'lists_truncated' => $missing_count > count( $missing ) || $mismatch_count > count( $mismatch ), ); } /** * Enumerate unexpected files only in the two core-owned directories. Root * extras such as wp-config.php and host control files are intentionally not * classified as core modifications. * * @param array $expected Official relative paths. * @return array{count:int,skipped:int} */ private function additional_protected_files( string $root, array $expected ): array { $count = 0; $skipped = 0; $seen = 0; foreach ( array( 'wp-admin', 'wp-includes' ) as $directory ) { $path = $root . '/' . $directory; if ( ! is_dir( $path ) || is_link( $path ) ) { ++$skipped; continue; } try { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ( $iterator as $entry ) { if ( ++$seen > self::MAX_FILES ) { ++$skipped; break 2; } if ( $entry->isLink() || ! $entry->isFile() ) { ++$skipped; continue; } $entry_path = wp_normalize_path( $entry->getPathname() ); if ( 0 !== strpos( $entry_path, $root . '/' ) ) { ++$skipped; continue; } $relative = substr( $entry_path, strlen( $root ) + 1 ); if ( ! isset( $expected[ $relative ] ) ) { ++$count; } } } catch ( \Throwable $exception ) { unset( $exception ); ++$skipped; } } return array( 'count' => $count, 'skipped' => $skipped, ); } private function safe_relative_path( string $path ): bool { return strlen( $path ) <= 512 && 1 === preg_match( '#^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$#D', $path ) && false === strpos( '/' . $path . '/', '/../' ) && false === strpos( '/' . $path . '/', '/./' ); } /** * @param list $items Bounded relative paths. */ private function append( array &$items, string $value ): void { if ( count( $items ) < self::MAX_LIST ) { $items[] = $value; } } }