1, 'stable' => true, 'checkpoints' => 0, 'changes' => array(), 'complete' => false, 'references' => array() ); try { $context = self::current( $scan['inventory'] ?? array(), $scan['roots'] ?? array() ); $scan['review_context']['start'] = $context; $scan['review_context']['last'] = $context; } catch ( \Throwable $error ) { self::unavailable( $scan, 'context_unavailable' ); } } /** Check each persisted batch and terminal inventory; a seen change stays recorded. */ public static function checkpoint( array &$scan, bool $final = false ): void { if ( 1 !== ( $scan['review_context']['schema_version'] ?? null ) ) { return; } try { $inventory = $final ? Inventory::collect() : ( $scan['inventory'] ?? array() ); $context = self::current( $inventory, $scan['roots'] ?? array() ); self::observe( $scan, $context ); if ( $final ) { $scan['review_context']['complete'] = true; $scan['review_context']['coverage'] = self::digest( array( 'coverage' => $scan['coverage'] ?? array(), 'mode' => $scan['mode'] ?? '', 'inventory_limits' => $inventory['limitations'] ?? array(), 'references' => $scan['review_context']['references'] ?? array() ) ); } } catch ( \Throwable $error ) { self::unavailable( $scan, 'context_unavailable' ); } } /** Provenance limits must never turn a valid reference into a failed scan. */ public static function referenceDigest( array $reference ): string { try { return self::digest( $reference ); } catch ( \Throwable $error ) { return ''; } } /** Bind actual previously loaded reference bytes without retrieving another copy. */ public static function references( array &$scan, array $references ): void { if ( 1 !== ( $scan['review_context']['schema_version'] ?? null ) ) { return; } foreach ( $references as $name => $digest ) { $key = hash( 'sha256', (string) $name ); if ( ! self::hash( $digest ) || count( $scan['review_context']['references'] ?? array() ) > 101 ) { self::unavailable( $scan, 'references_changed' ); continue; } $old = $scan['review_context']['references'][ $key ] ?? $digest; if ( ! hash_equals( $old, $digest ) ) { self::unavailable( $scan, 'references_changed' ); } $scan['review_context']['references'][ $key ] = $digest; } } /** Pure transition used by the scanner; does not authorize any operation. */ public static function observe( array &$scan, array $context ): void { if ( 1 !== ( $scan['review_context']['schema_version'] ?? null ) ) { return; } $before = $scan['review_context']['start'] ?? array(); foreach ( array( 'rules', 'settings', 'scope' ) as $key ) { if ( ! self::hash( $context[ $key ] ?? null ) || ! self::hash( $before[ $key ] ?? null ) ) { self::unavailable( $scan, 'context_unavailable' ); } elseif ( ! hash_equals( $before[ $key ], $context[ $key ] ) ) { self::unavailable( $scan, $key . '_changed' ); } } $scan['review_context']['last'] = array_intersect_key( $context, array_flip( array( 'rules', 'settings', 'scope' ) ) ); $scan['review_context']['checkpoints'] = min( 1000000, 1 + (int) ( $scan['review_context']['checkpoints'] ?? 0 ) ); } private static function unavailable( array &$scan, string $reason ): void { $scan['review_context']['stable'] = false; $scan['review_context']['changes'] = array_values( array_unique( array_merge( $scan['review_context']['changes'] ?? array(), array( $reason ) ) ) ); } private static function hash( $value ): bool { return is_string( $value ) && (bool) preg_match( '/^[a-f0-9]{64}$/D', $value ); } /** Bounded deterministic digest of local allowlisted inputs. Values are never exported. */ public static function digest( array $value ): string { $nodes = 0; $value = self::canonical( $value, $nodes ); $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR ); if ( strlen( $json ) > self::MAX_CONTEXT_BYTES ) { throw new \RuntimeException( 'Scan provenance input budget exceeded.' ); } return hash( 'sha256', $json ); } private static function canonical( $value, int &$nodes, int $depth = 0 ) { if ( ++$nodes > 50000 || $depth > 16 || ( is_string( $value ) && strlen( $value ) > self::MAX_CONTEXT_BYTES ) ) { throw new \RuntimeException( 'Scan provenance input budget exceeded.' ); } if ( ! is_array( $value ) ) { if ( ! is_scalar( $value ) && null !== $value ) { throw new \RuntimeException( 'Unsupported scan provenance input.' ); } return $value; } if ( ! array_is_list( $value ) ) { ksort( $value, SORT_STRING ); } foreach ( $value as $key => $item ) { $value[ $key ] = self::canonical( $item, $nodes, $depth + 1 ); } return $value; } private static function value( string $name, $default ) { // Refresh only each named value. Reloading the complete autoload cache for // every individual option would multiply work on large WordPress sites. if ( function_exists( 'wp_cache_delete' ) ) { wp_cache_delete( $name, 'options' ); } $value = get_option( $name, $default ); global $wpdb; if ( ! empty( $wpdb->last_error ) ) { throw new \RuntimeException( 'Scan provenance settings could not be read.' ); } return $value; } private static function option( string $name ): array { $value = self::value( $name, array() ); if ( ! is_array( $value ) ) { throw new \RuntimeException( 'Scan provenance settings are unavailable.' ); } return $value; } /** Reads existing local state only; does not query a feed or daemon. */ private static function current( array $inventory, array $roots ): array { global $wp_version, $wp_local_package; if ( function_exists( 'wp_cache_delete' ) ) { wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); } if ( ! empty( $inventory['limitations'] ) || empty( $inventory['paths'] ) || ! is_array( $inventory['core'] ?? null ) ) { throw new \RuntimeException( 'Scan inventory is incomplete.' ); } $settings = self::option( 'antivirus_ai_settings' ); $clam = self::option( 'antivirus_ai_clam_engine' ); $clamStatus = self::option( 'antivirus_ai_clam_status' ); $registry = Detector::registry(); // Include installed bytes as well as the currently evaluated bundle state, so a // bundle replaced while a PHP request holds a cached registry still differs. $signed = self::option( 'antivirus_ai_signed_rules' ); $rules = array( 'plugin_version' => defined( 'ANTIVIRUS_AI_VERSION' ) ? ANTIVIRUS_AI_VERSION : 'unknown', 'registry' => $registry, 'signed_envelope_digest' => hash( 'sha256', is_string( $signed['envelope'] ?? null ) ? $signed['envelope'] : '' ), 'clam_enabled' => true === ( $clam['enabled'] ?? false ), 'clam_database' => ! empty( $clam['enabled'] ) ? ( $clamStatus['database'] ?? array() ) : array(), ); $configuration = array( 'online_references' => ! empty( $settings['online_references'] ), 'vulnerability_feed' => ! empty( $settings['vulnerability_feed'] ), 'clam_generation' => $clam['generation'] ?? '', 'repository_confirmations' => self::option( 'antivirus_ai_repository_confirmations' ), 'active_plugins' => self::value( 'active_plugins', array() ), 'stylesheet' => self::value( 'stylesheet', '' ), 'template' => self::value( 'template', '' ), ); if ( ! empty( $settings['vulnerability_feed'] ) ) { $cache = self::option( 'antivirus_ai_vulnerability_cache' ); // Do not read a token or include the credential-derived cache binding. $configuration['feed_generation'] = self::value( 'antivirus_ai_feed_generation', '' ); $rules['feed'] = array_intersect_key( $cache, array_flip( array( 'schema_version', 'inventory_hash', 'fetched', 'records', 'complete', 'record_count', 'feed_generation' ) ) ); } $scope = array( 'core' => $inventory['core'], 'paths' => $inventory['paths'], 'roots' => $roots, 'runtime_core' => $wp_version ?? null, 'runtime_locale' => $wp_local_package ?? null ); foreach ( array( 'plugins', 'themes', 'mu_plugins', 'drop_ins' ) as $type ) { $items = $inventory[ $type ] ?? array(); if ( ! is_array( $items ) || count( $items ) > 2000 ) { throw new \RuntimeException( 'Scan inventory exceeds the provenance budget.' ); } $scope[ $type ] = array(); foreach ( $items as $item ) { if ( ! is_array( $item ) ) { throw new \RuntimeException( 'Unsupported component inventory.' ); } $scope[ $type ][] = self::digest( array_intersect_key( $item, array_flip( array( 'file', 'slug', 'path', 'version', 'name', 'update_uri', 'template', 'active' ) ) ) ); } sort( $scope[ $type ], SORT_STRING ); } return array( 'rules' => self::digest( $rules ), 'settings' => self::digest( $configuration ), 'scope' => self::digest( $scope ) ); } }