option( $name, $fallback ); if ( ( is_array( $fallback ) && ! is_array( $value ) ) || ( is_string( $fallback ) && ! is_string( $value ) ) ) { throw new \RuntimeException( 'Unsupported option schema.' ); } return $value; } catch ( \Throwable $e ) { $limitations[] = 'Inventory option ' . $name . ' was not decoded: ' . $e->getMessage(); return $fallback; } }; $paths = array( 'wordpress' => InstallationPaths::wordpress(), 'content' => InstallationPaths::content(), 'plugins' => InstallationPaths::plugins(), 'muplugins' => InstallationPaths::mustUse(), ); if ( function_exists( 'wp_upload_dir' ) ) { $uploads = wp_upload_dir( null, false, false ); // Do not create directories during read-only discovery. if ( empty( $uploads['error'] ) ) { $paths['uploads'] = rtrim( (string) $uploads['basedir'], '/' ); } else { $limitations[] = 'The configured uploads directory is unavailable.'; } } $themeRoots = InstallationPaths::themes(); foreach ( array_values( array_unique( $themeRoots ) ) as $i => $root ) { $paths[ 'themes' . $i ] = rtrim( (string) $root, '/' ); } $active = $option( 'active_plugins', array() ); $active = is_array( $active ) ? $active : array(); $stylesheet = (string) $option( 'stylesheet', '' ); $template = (string) $option( 'template', '' ); $plugins = self::plugins( $paths['plugins'], false, $active, $limitations ); $mu = self::plugins( $paths['muplugins'], true, array(), $limitations ); $themes = array(); foreach ( $themeRoots as $root ) { foreach ( self::entries( (string) $root, $limitations ) as $slug ) { try { $path = PathGuard::resolve( rtrim( (string) $root, '/' ), $slug . '/style.css' ); $header = self::header( rtrim( (string) $root, '/' ), $slug . '/style.css', array( 'name' => 'Theme Name', 'version' => 'Version', 'template' => 'Template', 'update_uri' => 'Update URI', ) ); if ( ! $header['name'] ) { continue; } $themes[] = $header + array( 'slug' => $slug, 'path' => dirname( $path ), 'active' => $slug === $stylesheet || $slug === $template, 'integrity' => 'unverified', ); if ( count( $themes ) >= 200 ) { $limitations[] = 'Theme inventory limit reached (200).'; break 2; } } catch ( \Throwable $e ) { if ( is_file( rtrim( (string) $root, '/' ) . '/' . $slug . '/style.css' ) ) { $limitations[] = 'Theme metadata not read: ' . $slug; } } } } $dropins = array(); foreach ( array( 'advanced-cache.php', 'db.php', 'db-error.php', 'install.php', 'maintenance.php', 'object-cache.php', 'php-error.php', 'fatal-error-handler.php', 'sunrise.php' ) as $file ) { if ( ! $paths['content'] || ! file_exists( $paths['content'] . '/' . $file ) ) { continue; } try { $header = self::header( $paths['content'], $file, array( 'name' => 'Plugin Name', 'version' => 'Version', ) ); $dropins[] = $header + array( 'file' => $file, 'path' => $paths['content'] . '/' . $file, 'integrity' => 'unverified', ); } catch ( \Throwable $e ) { $limitations[] = 'Drop-in metadata not read: ' . $file; } } $administrators = array(); if ( isset( $wpdb ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded prepared inventory query must observe current capability metadata and enforce the byte cap in SQL. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT u.ID,u.user_login,u.user_registered,CASE WHEN OCTET_LENGTH(m.meta_value)<=%d THEN m.meta_value ELSE NULL END AS capabilities FROM {$wpdb->users} u INNER JOIN {$wpdb->usermeta} m ON u.ID=m.user_id WHERE m.meta_key=%s AND m.meta_value LIKE %s ORDER BY u.ID LIMIT 1001", DatabaseInspector::MAX_VALUE_BYTES, $wpdb->prefix . 'capabilities', '%' . $wpdb->esc_like( 'administrator' ) . '%' ), ARRAY_A ); if ( ! is_array( $rows ) || ! empty( $wpdb->last_error ) ) { $limitations[] = 'Administrator inventory query failed.'; } else { if ( count( $rows ) > 1000 ) { $limitations[] = 'Administrator inventory limit reached.'; } foreach ( array_slice( $rows, 0, 1000 ) as $row ) { try { if ( ! is_string( $row['capabilities'] ?? null ) ) { throw new \RuntimeException( 'Role metadata exceeds the bounded read budget.' ); } $capabilities = DatabaseInspector::decodeValue( $row['capabilities'] ); if ( ! is_array( $capabilities ) ) { throw new \RuntimeException( 'Role metadata is not a capability map.' ); } if ( is_array( $capabilities ) && ! empty( $capabilities['administrator'] ) ) { $administrators[] = array( 'id' => (int) $row['ID'], 'login' => (string) $row['user_login'], 'registered_at' => (string) $row['user_registered'], ); } } catch ( \Throwable $e ) { $limitations[] = 'Administrator role metadata not inspected for user ' . (int) $row['ID']; } } } } else { $limitations[] = 'Administrator database inventory unavailable.'; } $tasks = array(); $cron = $option( 'cron', array() ); if ( is_array( $cron ) ) { foreach ( $cron as $timestamp => $hooks ) { if ( ! ctype_digit( (string) $timestamp ) || ! is_array( $hooks ) ) { continue; } foreach ( $hooks as $hook => $events ) { if ( ! is_array( $events ) ) { continue; } foreach ( $events as $event ) { if ( ! is_array( $event ) ) { continue; } $tasks[] = array( 'hook' => substr( (string) $hook, 0, 200 ), 'next_at' => gmdate( 'c', (int) $timestamp ), 'schedule' => substr( (string) ( $event['schedule'] ?? 'once' ), 0, 100 ), 'interval' => (int) ( $event['interval'] ?? 0 ), ); if ( count( $tasks ) >= 500 ) { $limitations[] = 'Scheduled-task inventory limit reached (500).'; break 3; } } } } } return array( 'observed_at' => gmdate( 'c' ), 'trust' => 'untrusted_observation', 'core' => array( 'version' => (string) ( $wp_version ?? 'unknown' ), // The UI/site language can differ from the installed core distribution. 'locale' => is_string( $wp_local_package ?? null ) && '' !== $wp_local_package ? $wp_local_package : 'en_US', 'integrity' => 'unverified', ), 'plugins' => $plugins, 'themes' => $themes, 'mu_plugins' => $mu, 'drop_ins' => $dropins, 'administrators' => $administrators, 'scheduled_tasks' => $tasks, 'paths' => array_filter( $paths ), 'limitations' => array_slice( array_values( array_unique( $limitations ) ), 0, 100 ), 'coverage' => 'Header discovery follows configured WordPress paths. Symlinked components, unsupported objects, and inventory limits are exposed; no observed state is trusted automatically.', ); } private static function plugins( string $root, bool $mu, array $active, array &$limitations ): array { $out = array(); if ( ! $root ) { return $out; } foreach ( self::entries( $root, $limitations ) as $entry ) { $files = array(); try { $path = PathGuard::resolve( $root, $entry ); if ( is_dir( $path ) && ! $mu ) { foreach ( self::entries( $path, $limitations ) as $child ) { if ( str_ends_with( strtolower( $child ), '.php' ) ) { $files[] = $entry . '/' . $child; } } } elseif ( is_file( $path ) && str_ends_with( strtolower( $entry ), '.php' ) ) { $files[] = $entry; } } catch ( \Throwable $e ) { $limitations[] = 'Component discovery skipped ' . $entry . ': unsupported path.'; continue; } foreach ( $files as $file ) { try { $header = self::header( $root, $file, array( 'name' => 'Plugin Name', 'version' => 'Version', 'text_domain' => 'Text Domain', 'update_uri' => 'Update URI', ) ); if ( ! $header['name'] ) { continue; } $out[] = $header + array( 'file' => $file, 'slug' => str_contains( $file, '/' ) ? explode( '/', $file )[0] : basename( $file, '.php' ), 'path' => $root . '/' . $file, 'active' => $mu || in_array( $file, $active, true ), 'integrity' => 'unverified', 'identity_basis' => 'local header and directory; repository ownership unconfirmed', ); if ( count( $out ) >= 200 ) { $limitations[] = 'Plugin inventory limit reached (200).'; return $out; } } catch ( \Throwable $e ) { $limitations[] = 'Plugin metadata not read: ' . $file; } } } return $out; } private static function entries( string $root, array &$limitations ): array { if ( ! is_dir( $root ) ) { return array(); } try { PathGuard::noLinks( $root ); $dir = @opendir( $root ); if ( ! $dir ) { throw new \RuntimeException( 'Unreadable directory.' ); } $out = array(); try { while ( false !== $entry = readdir( $dir ) ) { if ( '.' === $entry || '..' === $entry || str_starts_with( $entry, '.' ) ) { continue; } if ( ++self::$entryCount > 5000 || microtime( true ) > self::$deadline ) { $limitations[] = 'Inventory discovery budget reached (5,000 entries or two seconds).'; break; } $out[] = $entry; if ( count( $out ) >= 5000 ) { $limitations[] = 'Component directory entry limit reached: ' . $root; break; } } } finally { closedir( $dir ); } sort( $out, SORT_STRING ); return $out; } catch ( \Throwable $e ) { $limitations[] = 'Component directory is unreadable or symlinked: ' . $root; return array(); } } public static function component( string $root, string $file, string $type ): array { self::$deadline = microtime( true ) + 2; $theme = 'theme' === $type; if ( ! $theme && 'plugin' !== $type ) { throw new \RuntimeException( 'Unsupported component type.' ); } $header = self::header( $root, $file, array( 'name' => $theme ? 'Theme Name' : 'Plugin Name', 'version' => 'Version', 'update_uri' => 'Update URI' ) ); $slug = explode( '/', $file )[0]; return $header + array( 'slug' => $slug, 'file' => $file, 'path' => $theme ? $root . '/' . $slug : $root . '/' . $file ); } private static function header( string $root, string $relative, array $fields ): array { if ( microtime( true ) > self::$deadline ) { throw new \RuntimeException( 'Inventory time budget reached.' ); } $path = PathGuard::resolve( $root, $relative ); $before = lstat( $path ); if ( ! is_file( $path ) || ! is_readable( $path ) ) { throw new \RuntimeException( 'Unreadable component header.' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Bounded local reads inspect installed source without loading or executing it. $handle = @fopen( $path, 'rb' ); if ( ! $handle ) { throw new \RuntimeException( 'Unreadable component header.' ); } try { $opened = fstat( $handle ); if ( $opened['dev'] !== $before['dev'] || $opened['ino'] !== $before['ino'] || 1 !== $opened['nlink'] ) { throw new \RuntimeException( 'Component changed before header read.' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Bounded local reads inspect installed source without loading or executing it. $text = fread( $handle, 8192 ); if ( ! is_string( $text ) ) { throw new \RuntimeException( 'Header read failed.' ); } PathGuard::resolve( $root, $relative ); $out = array(); foreach ( $fields as $key => $label ) { $out[ $key ] = preg_match( '/^[ \t\/*#@]*' . preg_quote( $label, '/' ) . ':\s*(.{0,500})$/mi', str_replace( "\r", "\n", $text ), $m ) ? substr( trim( preg_replace( '/\s*(?:\*\/|\?>).*$/', '', $m[1] ) ), 0, 200 ) : ''; } return $out; } finally { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Bounded local reads inspect installed source without loading or executing it. fclose( $handle ); } } }