*/ private $component_source; private ComponentEvidenceSanitizer $sanitizer; /** * @param callable():array|null $component_source Testable local component/update-cache adapter. */ public function __construct( FindingFactory $findings, ?callable $component_source = null, ?string $hash_key = null ) { $this->findings = $findings; $this->component_source = $component_source ?? array( $this, 'wordpress_components' ); $this->sanitizer = new ComponentEvidenceSanitizer( $hash_key ?? ( new LocalHashKey() )->derive() ); } public function id(): string { return 'components.provenance_updates'; } /** * @return array */ public function execute( string $timestamp ): array { try { $components = call_user_func( $this->component_source ); } catch ( Throwable $exception ) { return $this->error_finding( $timestamp ); } if ( ! is_array( $components ) ) { return $this->error_finding( $timestamp ); } $total = count( $components ); $truncated = $total > self::MAX_COMPONENTS; $objects = array(); $observed = 0; $updates_available = 0; $invalid_channels = 0; foreach ( array_slice( $components, 0, self::MAX_COMPONENTS ) as $component ) { if ( ! is_array( $component ) || ! isset( $component['type'], $component['identifier'], $component['state'], $component['update_channel'], $component['update_state'] ) || ! is_string( $component['type'] ) || ! is_string( $component['identifier'] ) || ! is_string( $component['state'] ) || ! is_string( $component['update_channel'] ) || ! is_string( $component['update_state'] ) || '' === $component['identifier'] ) { continue; } if ( ! in_array( $component['type'], self::TYPES, true ) || ! in_array( $component['state'], self::STATES, true ) || ! in_array( $component['update_channel'], self::CHANNELS, true ) || ! in_array( $component['update_state'], self::UPDATE_STATES, true ) ) { continue; } $objects[] = array( 'type' => $component['type'], 'reference' => $this->sanitizer->reference( $component['identifier'] ), 'name' => isset( $component['name'] ) && is_string( $component['name'] ) ? $this->sanitizer->text( $component['name'] ) : $component['type'], 'version' => isset( $component['version'] ) && is_string( $component['version'] ) ? $this->sanitizer->version( $component['version'] ) : 'unknown', 'state' => $component['state'], 'update_channel' => $component['update_channel'], 'cached_update_state' => $component['update_state'], ); if ( 'cache_unavailable' !== $component['update_state'] && 'not_checked' !== $component['update_state'] ) { ++$observed; } if ( 'update_available' === $component['update_state'] ) { ++$updates_available; } if ( 'invalid_custom_channel' === $component['update_channel'] ) { ++$invalid_channels; } } usort( $objects, static function ( array $left, array $right ): int { return array( $left['type'], $left['reference'] ) <=> array( $right['type'], $right['reference'] ); } ); if ( 0 === count( $objects ) ) { $status = 'not_tested'; $coverage = 'none'; } elseif ( $invalid_channels > 0 ) { $status = 'fail'; $coverage = ! $truncated && $observed === $total ? 'complete' : 'partial'; } elseif ( $updates_available > 0 ) { $status = 'warning'; $coverage = ! $truncated && $observed === $total ? 'complete' : 'partial'; } elseif ( $truncated || $observed < $total ) { $status = 'not_tested'; $coverage = 0 === $observed ? 'none' : 'partial'; } else { $status = 'pass'; $coverage = 'complete'; } return $this->findings->build( $this->id(), $status, array( 'objects' => $objects, 'observed_count' => $observed, 'total_count' => $total, 'truncated' => $truncated, 'limitations' => array( 'Only local headers and existing WordPress update-cache entries were read. The default channel is not proof of WordPress.org provenance; a cached update is not classified as security-related; no update service was contacted.' ), ), $observed, $total, $coverage, $timestamp ); } /** * @return array */ private function wordpress_components(): array { if ( ! function_exists( 'get_plugins' ) ) { $plugin_api = ( new WordPressPathProvider() )->core_file( 'wp-admin/includes/plugin.php' ); require_once $plugin_api; } $plugin_cache = $this->update_cache( 'plugins' ); $theme_cache = $this->update_cache( 'themes' ); $active = array_map( 'strval', (array) get_option( 'active_plugins', array() ) ); $network = is_multisite() ? array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) : array(); $components = array(); foreach ( get_plugins() as $identifier => $headers ) { $identifier = (string) $identifier; $state = in_array( $identifier, $network, true ) ? 'network_active' : ( in_array( $identifier, $active, true ) ? 'active' : 'inactive' ); $components[] = array( 'type' => 'plugin', 'identifier' => $identifier, 'name' => isset( $headers['Name'] ) && is_string( $headers['Name'] ) ? $headers['Name'] : 'plugin', 'version' => isset( $headers['Version'] ) && is_string( $headers['Version'] ) ? $headers['Version'] : 'unknown', 'state' => $state, 'update_channel' => $this->update_channel( isset( $headers['UpdateURI'] ) && is_string( $headers['UpdateURI'] ) ? $headers['UpdateURI'] : '' ), 'update_state' => $this->update_state( $identifier, $plugin_cache ), ); } foreach ( ( new LocalThemeAdapter() )->themes() as $theme ) { $components[] = array( 'type' => 'theme', 'identifier' => $theme['identifier'], 'name' => $theme['name'], 'version' => $theme['version'], 'state' => $theme['state'], 'update_channel' => $this->update_channel( $theme['update_uri'] ), 'update_state' => $this->update_state( $theme['slug'], $theme_cache ), ); } return $components; } /** * @return array{state:string,data:array} */ private function update_cache( string $type ): array { if ( wp_using_ext_object_cache() ) { return array( 'state' => 'external_cache_unobserved', 'data' => array(), ); } $option = '_site_transient_update_' . $type; $timeout = '_site_transient_timeout_update_' . $type; $raw = is_multisite() ? get_site_option( $option, null ) : get_option( $option, null ); $expires = is_multisite() ? get_site_option( $timeout, null ) : get_option( $timeout, null ); if ( null === $raw || false === $raw ) { $state = 'missing'; } elseif ( is_numeric( $expires ) && (int) $expires > 0 && (int) $expires < time() ) { $state = 'expired'; } else { $state = 'current'; } return array( 'state' => $state, 'data' => is_object( $raw ) ? get_object_vars( $raw ) : ( is_array( $raw ) ? $raw : array() ), ); } /** * @param array{state:string,data:array} $cache Local cache projection. */ private function update_state( string $identifier, array $cache ): string { if ( 'current' !== $cache['state'] ) { return 'cache_unavailable'; } foreach ( array( 'response' => 'update_available', 'no_update' => 'no_cached_update', 'checked' => 'no_cached_update', ) as $field => $state ) { $values = $cache['data'][ $field ] ?? array(); $values = is_object( $values ) ? get_object_vars( $values ) : $values; if ( is_array( $values ) && array_key_exists( $identifier, $values ) ) { return $state; } } return 'not_checked'; } private function update_channel( string $uri ): string { $uri = trim( $uri ); if ( '' === $uri ) { return 'default_channel'; } if ( 'false' === strtolower( $uri ) ) { return 'custom_channel_declared'; } $parts = wp_parse_url( $uri ); if ( ! is_array( $parts ) || ! isset( $parts['scheme'], $parts['host'] ) || ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) || '' === $parts['host'] ) { return 'invalid_custom_channel'; } return 'custom_channel_declared'; } /** * @return array */ private function error_finding( string $timestamp ): array { return $this->findings->build( $this->id(), 'error', array( 'objects' => array(), 'observed_count' => 0, 'total_count' => 0, 'truncated' => false, 'limitations' => array( 'The local provenance/update adapter failed; no exception, component identifier, path, URI or cached response was stored.' ), ), 0, 0, 'error', $timestamp ); } }