host_probe = $host_probe ?? new ProcfsHostProbe(); $this->allow_host_probe = $allow_host_probe; $this->paths = $paths ?? new WordPressPathProvider(); } public function collect( string $requested_profile = 'auto' ): ServerObservationSet { if ( ! in_array( $requested_profile, array( 'auto', 'shared', 'vps', 'container' ), true ) ) { throw new InvalidArgumentException( 'invalid_server_profile' ); } $container = $this->container_state(); if ( 'auto' === $requested_profile ) { $profile = 'detected' === $container ? 'container' : 'unclassified'; $profile_source = 'detected' === $container ? 'detected' : 'undetermined'; } else { $profile = $requested_profile; $profile_source = 'user'; } $allow_host_probe = $this->allow_host_probe && in_array( $requested_profile, array( 'vps', 'container' ), true ); $sections = array( 'server.runtime_services' => $this->runtime( $profile, $profile_source, $container ), 'server.privileges_processes' => $this->privileges( $allow_host_probe ), 'server.filesystem_resources' => $this->filesystem(), 'server.database' => $this->database(), 'server.scheduled_work' => $this->scheduled_work(), 'server.tls_http' => $this->tls_http(), 'server.backup_logging' => $this->backup_logging(), 'server.integrity' => $this->integrity(), ); $available = 0; $partial = 0; foreach ( $sections as $section ) { if ( 'available' === $section['state'] ) { ++$available; } elseif ( 'partial' === $section['state'] ) { ++$partial; } } $sections['server.coverage_profile'] = $this->section( 'server.coverage_profile', 'available', array( 'profile' => $profile, 'available_sections' => $available, 'partial_sections' => $partial, 'unavailable_sections' => 8 - $available - $partial, 'total_sections' => 8, 'host_probe' => $this->host_probe_state( $sections ), ), 8, 8, 'standard', array(), array() ); return new ServerObservationSet( $sections ); } /** * @return array */ private function runtime( string $profile, string $profile_source, string $container ): array { $os = strtolower( PHP_OS_FAMILY ); $os = in_array( $os, array( 'windows', 'linux', 'bsd', 'darwin', 'solaris' ), true ) ? $os : 'unknown'; return $this->section( 'server.runtime_services', 'available', array( 'os_family' => $os, 'architecture' => $this->architecture( php_uname( 'm' ) ), 'php_version' => PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, 'php_sapi' => $this->php_sapi( PHP_SAPI ), 'extensions_loaded' => min( 1000000, count( get_loaded_extensions() ) ), 'ini_state' => false === php_ini_loaded_file() ? 'unavailable' : 'loaded', 'profile' => $profile, 'profile_source' => $profile_source, 'container_state' => $container, ), 9, 9, 'standard', array(), array() ); } /** * @return array */ private function privileges( bool $allow_host_probe ): array { $identity = function_exists( 'posix_geteuid' ) ? ( 0 === posix_geteuid() ? 'root' : 'nonroot' ) : 'not_tested'; $facts = $allow_host_probe ? $this->host_probe->process_and_listener_facts() : array( 'process_probe' => 'not_tested', 'listener_probe' => 'not_tested', ); $values = array_merge( array( 'identity' => $identity ), $facts ); $seen = 'not_tested' === $identity ? 0 : 1; foreach ( array( 'process_probe', 'listener_probe' ) as $probe ) { if ( 'available' === $values[ $probe ] ) { ++$seen; } } $state = 0 === $seen ? 'not_tested' : ( 3 === $seen ? 'available' : 'partial' ); return $this->section( 'server.privileges_processes', $state, $values, $seen, 3, 'standard', array(), 3 === $seen ? array() : array( 'host_procfs_unavailable_or_restricted' ) ); } /** * @return array */ private function filesystem(): array { $paths = array( 'wordpress_root' => $this->paths->wordpress_root(), 'content_root' => $this->paths->content_root(), 'uploads_root' => $this->paths->uploads_root(), ); $values = array( 'inode_state' => 'not_tested' ); $masked = array(); $seen = 0; foreach ( $paths as $key => $path ) { if ( '' === $path || ! file_exists( $path ) ) { continue; } $values[ $key . '_readable' ] = is_readable( $path ); // Server diagnostics intentionally observe permission metadata without // initializing WP_Filesystem or changing the target. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable $values[ $key . '_writable' ] = is_writable( $path ); $masked[] = '{' . str_replace( '_', '-', $key ) . '}'; ++$seen; } $root = $paths['wordpress_root']; if ( '' !== $root && file_exists( $root ) ) { $total = $this->guarded_space( 'disk_total_space', $root ); $free = $this->guarded_space( 'disk_free_space', $root ); if ( null !== $total && null !== $free && $total > 0 ) { $percent = max( 0, min( 100, (int) floor( ( $free / $total ) * 100 ) ) ); $values['disk_free_percent'] = $percent; $values['disk_free_band'] = $percent < 5 ? 'low' : ( $percent < 15 ? 'watch' : 'healthy' ); ++$seen; } } return $this->section( 'server.filesystem_resources', 0 === $seen ? 'not_tested' : ( 4 === $seen ? 'available' : 'partial' ), $values, $seen, 4, 'standard', $masked, array( 'inode_capacity_not_tested' ) ); } /** * @return array */ private function database(): array { global $wpdb; if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! method_exists( $wpdb, 'db_version' ) ) { return $this->section( 'server.database', 'not_tested', array( 'family' => 'unknown', 'version' => 'unknown', 'connection_state' => 'unavailable', ), 0, 2, 'standard', array(), array( 'wordpress_database_adapter_unavailable' ) ); } $fallback = (string) $wpdb->db_version(); $server_info = method_exists( $wpdb, 'db_server_info' ) ? (string) $wpdb->db_server_info() : $fallback; if ( false !== stripos( $server_info, 'mariadb' ) ) { $family = 'mariadb'; } elseif ( 1 === preg_match( '/\b(?:percona|tidb|aurora)\b/i', $server_info ) ) { $family = 'unknown'; } else { $family = 1 === preg_match( '/^[0-9]{1,3}\.[0-9]{1,3}/', trim( $server_info ) ) ? 'mysql' : 'unknown'; } $version = 'unknown'; if ( 'mariadb' === $family && 1 === preg_match( '/(?:^|[^0-9])([0-9]{1,3}\.[0-9]{1,3})(?:\.[0-9]{1,3})?-mariadb\b/i', $server_info, $match ) ) { $version = $match[1]; } elseif ( 1 === preg_match( '/^([0-9]{1,3}\.[0-9]{1,3})/', trim( $fallback ), $match ) ) { $version = $match[1]; } $observed = ( 'unknown' === $family ? 0 : 1 ) + ( 'unknown' === $version ? 0 : 1 ); return $this->section( 'server.database', 0 === $observed ? 'not_tested' : ( 2 === $observed ? 'available' : 'partial' ), array( 'family' => $family, 'version' => $version, 'connection_state' => 'available', ), $observed, 2, 'standard', array(), 2 === $observed ? array() : array( 'database_banner_not_classified' ) ); } /** * @return array */ private function scheduled_work(): array { $values = array( 'wp_cron_state' => 'not_tested' ); $seen = 0; if ( function_exists( '_get_cron_array' ) ) { $values['wp_cron_state'] = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ? 'disabled' : 'enabled'; $values['scheduled_event_count'] = $this->scheduled_event_count(); $seen = 2; } $values['system_timer_state'] = 'not_tested'; return $this->section( 'server.scheduled_work', 0 === $seen ? 'not_tested' : 'partial', $values, $seen, 3, 'standard', array(), array( 'system_scheduler_requires_manual_evidence' ) ); } /** * @return array */ private function tls_http(): array { $scheme = 'not_tested'; if ( function_exists( 'home_url' ) ) { $parsed = wp_parse_url( home_url( '/' ), PHP_URL_SCHEME ); $scheme = in_array( $parsed, array( 'http', 'https' ), true ) ? $parsed : 'other'; } $web_server = $this->web_server_family(); $observed = ( 'not_tested' === $scheme ? 0 : 1 ) + ( 'unknown' === $web_server ? 0 : 1 ); return $this->section( 'server.tls_http', 0 === $observed ? 'not_tested' : 'partial', array( 'home_scheme' => $scheme, 'web_server_family' => $web_server, 'local_certificate_state' => 'not_tested', 'header_state' => 'not_tested', ), $observed, 4, 'standard', array(), array( 'network_tls_and_headers_not_requested' ) ); } /** * @return array */ private function backup_logging(): array { $debug = defined( 'WP_DEBUG_LOG' ) ? ( WP_DEBUG_LOG ? 'enabled' : 'disabled' ) : 'not_tested'; $observed = 'not_tested' === $debug ? 0 : 1; return $this->section( 'server.backup_logging', 0 === $observed ? 'not_tested' : 'partial', array( 'backup_evidence' => 'not_tested', 'debug_log_state' => $debug, 'log_rotation_state' => 'not_tested', 'restore_test_state' => 'not_tested', ), $observed, 4, 'standard', array(), array( 'backup_and_restore_require_manual_evidence', 'log_rotation_not_visible_to_php' ) ); } /** * @return array */ private function integrity(): array { return $this->section( 'server.integrity', 'not_tested', array( 'core_checksum_state' => 'not_tested', 'package_manifest_state' => 'not_tested', ), 0, 2, 'standard', array(), array( 'integrity_requires_explicit_local_checksum_adapter' ) ); } /** * @param array $values Safe observation values. * @param list $masked_paths Masked path labels. * @param list $limitations Limitation identifiers. * @return array */ private function section( string $capability, string $state, array $values, int $observed, int $total, string $permission, array $masked_paths, array $limitations ): array { return array( 'capability' => $capability, 'state' => $state, 'values' => $values, 'masked_paths' => $masked_paths, 'permission' => $permission, 'limitations' => $limitations, 'observed' => $observed, 'total' => $total, ); } private function architecture( string $raw ): string { $value = strtolower( trim( $raw ) ); if ( in_array( $value, array( 'x86_64', 'amd64' ), true ) ) { return 'x86_64'; } if ( in_array( $value, array( 'aarch64', 'arm64' ), true ) ) { return 'aarch64'; } if ( 1 === preg_match( '/^(?:i[3-6]86|x86)$/', $value ) ) { return 'x86'; } if ( 0 === strpos( $value, 'arm' ) ) { return 'arm'; } return '' === $value ? 'unknown' : 'other'; } private function php_sapi( string $raw ): string { $value = strtolower( trim( $raw ) ); return in_array( $value, array( 'cli', 'cli-server', 'fpm-fcgi', 'apache2handler', 'cgi-fcgi', 'litespeed' ), true ) ? $value : 'other'; } private function container_state(): string { if ( 'Linux' !== PHP_OS_FAMILY ) { return 'not_tested'; } if ( is_file( '/.dockerenv' ) || is_file( '/run/.containerenv' ) ) { return 'detected'; } if ( is_readable( '/proc/1/cgroup' ) ) { // The fixed kernel metadata file is read only for known container markers; // its contents never cross the normalized observation boundary. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $cgroup = file_get_contents( '/proc/1/cgroup', false, null, 0, 8192 ); if ( is_string( $cgroup ) && 1 === preg_match( '/(?:docker|containerd|kubepods|lxc|podman)/i', $cgroup ) ) { return 'detected'; } } return 'not_detected'; } private function scheduled_event_count(): int { if ( ! function_exists( '_get_cron_array' ) ) { return 0; } $cron = (array) _get_cron_array(); $count = 0; foreach ( array_slice( $cron, 0, 10000, true ) as $hooks ) { if ( ! is_array( $hooks ) ) { continue; } foreach ( array_slice( $hooks, 0, 1000, true ) as $events ) { $count += is_array( $events ) ? min( 1000, count( $events ) ) : 0; if ( $count >= 1000000 ) { return 1000000; } } } return $count; } private function web_server_family(): string { $software = isset( $_SERVER['SERVER_SOFTWARE'] ) && is_string( $_SERVER['SERVER_SOFTWARE'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) ) : ''; foreach ( array( 'apache', 'nginx', 'litespeed', 'iis', 'caddy' ) as $family ) { if ( false !== strpos( $software, $family ) ) { return $family; } } return '' === $software ? 'unknown' : 'other'; } private function guarded_space( string $space_function, string $path ): ?float { // The temporary handler converts local filesystem warnings to unavailable // coverage; it never logs, renders, or stores warning text. // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler $previous = set_error_handler( static fn (): bool => true ); try { $value = 'disk_total_space' === $space_function ? disk_total_space( $path ) : disk_free_space( $path ); } finally { restore_error_handler(); unset( $previous ); } return false === $value ? null : (float) $value; } /** * @param array> $sections Collected sections. */ private function host_probe_state( array $sections ): string { $privileges = $sections['server.privileges_processes']['values']; foreach ( array( $privileges['process_probe'], $privileges['listener_probe'] ) as $state ) { if ( 'available' === $state ) { return 'available'; } } return 'not_tested'; } }