503 ) ); } /** * Revalidate each temporal row against its exact still-live local proof and connector. * * @param \wpdb $db Native connection whose context and authority are sealed by the caller. * @param int $network Current network identifier. * @param int $site Current site identifier. * @param string $uid UUID of the retained case. * @param array $rows Complete bounded case link inventory, including legacy local rows. * @param array $proofs Private legacy projection map keyed by stored link ID, with row/live/until. * @param array $policy Current request-retention policy used to bound reference validity. * @param int $event_days Current event-retention duration in days. * @param bool $sensitive Whether the caller may expose references backed by weak sensitive proof. * @return array|\WP_Error Minimized indexed projections plus current connector binding and deadline. */ public static function project( \wpdb $db, int $network, int $site, string $uid, array $rows, array $proofs, array $policy, int $event_days, bool $sensitive ): array|\WP_Error { if ( $network < 1 || $site < 1 || ! self::uuid( $uid ) || ! array_is_list( $rows ) || count( $rows ) > 100 || $event_days < 1 || $event_days > 3650 ) { return self::error(); } $sources = RequestCaseServerSource::inventory( $db, $network, $site ); if ( is_wp_error( $sources ) ) { return $sources; } $cases = IncidentPrivacy::tables( $db )['cases']; if ( ! self::table( $cases ) ) { return self::error(); } $case_rows = RequestCaseLayout::rows( $db, $db->prepare( "SELECT uid,revision,expires_at,rule,rule_version,source,actor_id,configuration_hash,CASE WHEN OCTET_LENGTH(configuration)<=8192 THEN configuration ELSE NULL END AS configuration FROM %i WHERE network_id=%d AND site_id=%d AND uid=%s LIMIT 1 LOCK IN SHARE MODE", $cases, $network, $site, $uid ), 1 ); if ( null === $case_rows ) { return self::error(); } $case = $case_rows[0] ?? null; $out = array(); $until = null; $clock = time(); foreach ( $rows as $row ) { if ( 'temporal' !== ( $row['strength'] ?? null ) ) { continue; } if ( ! self::id( $row['id'] ?? null ) || ! in_array( $row['availability'] ?? null, array( 'retained', 'erased', 'retention_removed' ), true ) || ! in_array( $row['source'] ?? null, array( 'server_log', 'synthetic_test' ), true ) || null === self::epoch( $row['occurred_at'] ?? null ) || null === self::epoch( $row['ingested_at'] ?? null ) || null === self::epoch( $row['expires_at'] ?? null ) ) { return self::error(); } $proof = $proofs[ (string) ( $row['proof_id'] ?? '' ) ] ?? null; $available = 'retained' === $row['availability']; $reason = $available ? 'source_unavailable' : $row['availability']; if ( $available ) { foreach ( array( 'event', 'request', 'anchor', 'probe' ) as $kind ) { if ( ! self::id( $row[ $kind . '_id' ] ?? null ) || ! self::uuid( $row[ $kind . '_uid' ] ?? null ) ) { return self::error(); } } foreach ( array( 'link_key', 'proof_key', 'source_key', 'source_binding', 'request_binding' ) as $field ) { if ( ! is_string( $row[ $field ] ?? null ) || ! preg_match( '/\A[a-f0-9]{64}\z/D', $row[ $field ] ) ) { return self::error(); } } if ( ! self::id( $row['proof_id'] ?? null ) || ! self::uuid( $row['connector_uid'] ?? null ) || '1' !== (string) ( $row['relation_version'] ?? '' ) || null !== $row['source_generation'] ) { return self::error(); } } $live = $available && null !== $case && 'request_sensitive_change' === $case['rule'] && self::id( $case['revision'] ) && self::epoch( $case['expires_at'] ) > $clock && self::epoch( $row['expires_at'] ) > $clock && is_array( $proof ) && true === $proof['live']; if ( is_array( $proof ) && 'weak' === $proof['row']['strength'] && ! $sensitive ) { $reason = 'permission_masked'; } $source = $sources['sources'][ $row['source_key'] ?? '' ] ?? null; $live = $live && null !== $source && $source['available'] && $source['uid'] === $row['connector_uid'] && $source['binding'] === $row['source_binding']; $deadline = null; if ( $live ) { $original = $proof['row']; $live = $original['link_key'] === $row['proof_key'] && $original['request_id'] === $row['probe_id'] && $original['request_uid'] === $row['probe_uid'] && $original['family'] === $row['family'] && in_array( $original['strength'], array( 'strong', 'weak' ), true ); foreach ( array( 'case_uid', 'event_id', 'event_uid', 'anchor_id', 'anchor_uid' ) as $field ) { $live = $live && $original[ $field ] === $row[ $field ]; } if ( $live ) { $current = self::current( $db, $network, $site, $row ); if ( is_wp_error( $current ) ) { return $current; } $deadline = null === $current ? null : self::revalidate( $network, $site, $case, $row, $original, $proof['until'], $current, $policy, $event_days, count( $rows ), $clock ); $live = null !== $deadline; } } if ( $live ) { $until = null === $until ? $deadline : min( $until, $deadline ); } $out[ (string) $row['id'] ] = array( 'strength' => 'temporal', 'basis' => 'server_pattern_window', 'availability' => $live ? 'retained' : $reason, 'request_id' => $live ? $row['request_id'] : null, 'request_uid' => $live ? $row['request_uid'] : null, 'event_id' => $live ? $row['event_id'] : null, 'event_uid' => $live ? $row['event_uid'] : null, 'probe_id' => $live ? $row['probe_id'] : null, 'probe_uid' => $live ? $row['probe_uid'] : null, 'occurred_at' => $row['occurred_at'], 'ingested_at' => $row['ingested_at'], 'source' => $row['source'], 'identity_inference' => 'not_made', 'meaning' => 'A matching server-reported pattern occurred in the case window. Coincidence, ordinary scanning and authorized testing are alternatives; actor identity and application effect are unknown.', ); } return array( 'links' => $out, 'source_binding' => $sources['binding'], 'valid_until' => $until, ); } /** * Read one bounded, address-free projection of all four exact source identities. * * @param \wpdb $db Native connection selected by the owning reader. * @param int $network Network identifier constraining every joined source. * @param int $site Site identifier constraining every joined source. * @param array $link Stored request, probe, anchor and event identifiers. * @return array|\WP_Error|null Current joined row, null when missing, or an error when the read fails. */ private static function current( \wpdb $db, int $network, int $site, array $link ): array|\WP_Error|null { $requests = $db->base_prefix . 'logged_ai_requests'; $events = $db->base_prefix . 'logged_ai_events'; if ( ! self::table( $requests ) || ! self::table( $events ) ) { return self::error(); } $sql = $db->prepare( "SELECT r.id,r.request_uid,r.network_id,r.site_id,r.captured_at,r.ingested_at,r.sensor,r.source,r.family,r.safe_path,r.method,r.partial,r.source_quality,r.source_generation,r.own_response,r.alert_state,r.effect,r.parser_version,r.rule_id,r.rule_revision,CASE WHEN OCTET_LENGTH(r.evidence)<=4096 THEN r.evidence ELSE NULL END AS evidence,p.captured_at AS probe_at,p.family AS probe_family,p.safe_path AS probe_path,p.source AS probe_source,p.sensor AS probe_sensor,p.partial AS probe_partial,a.captured_at AS anchor_at,a.source AS anchor_source,a.sensor AS anchor_sensor,a.partial AS anchor_partial,e.occurred_at AS event_at,e.request_id AS event_request_uid,e.code AS event_code,e.actor_kind AS event_actor_kind,e.actor_id AS event_actor_id,e.subject_id AS event_subject_id,e.outcome AS event_outcome,e.ingested_at AS event_ingested_at,CASE WHEN OCTET_LENGTH(e.payload)<=16384 THEN e.payload ELSE NULL END AS event_payload FROM %i r JOIN %i p ON p.network_id=r.network_id AND p.site_id=r.site_id AND p.id=%s AND p.request_uid=%s JOIN %i a ON a.network_id=r.network_id AND a.site_id=r.site_id AND a.id=%s AND a.request_uid=%s JOIN %i e ON e.network_id=r.network_id AND e.site_id=r.site_id AND e.id=%s AND e.uid=%s WHERE r.network_id=%d AND r.site_id=%d AND r.id=%s AND r.request_uid=%s LIMIT 1 LOCK IN SHARE MODE", $requests, $requests, $link['probe_id'], $link['probe_uid'], $requests, $link['anchor_id'], $link['anchor_uid'], $events, $link['event_id'], $link['event_uid'], $network, $site, $link['request_id'], $link['request_uid'] ); $rows = RequestCaseLayout::rows( $db, $sql, 1 ); return null === $rows ? self::error() : ( $rows[0] ?? null ); } /** * Interpret stored references using the same finite semantic contract used at admission. * * @param int $network Current network identifier. * @param int $site Current site identifier. * @param array $case_row Retained case with its immutable rule configuration. * @param array $row Stored temporal reference being checked. * @param array $proof Original local strong or weak relation. * @param string $proof_until Current UTC expiry of the local proof. * @param array $current Fresh joined projection of the four source identities. * @param array $policy Current request-retention policy. * @param int $event_days Current event-retention duration in days. * @param int $references Complete count of retained case links. * @param int $clock Current evaluation epoch in seconds. * @return string|null Earliest current UTC expiry, or null when the reference cannot be reproduced. */ private static function revalidate( int $network, int $site, array $case_row, array $row, array $proof, string $proof_until, array $current, array $policy, int $event_days, int $references, int $clock ): ?string { try { $detail = is_string( $current['event_payload'] ) ? json_decode( $current['event_payload'], true, 16 ) : null; $source = is_array( $detail ) ? ( $detail['source'] ?? null ) : null; if ( ! self::mutation( $case_row, $current, $detail ) || ! in_array( $source, array( 'wordpress', 'synthetic_test' ), true ) || $source !== $case_row['source'] || $source !== $proof['source'] || ! empty( $detail['incomplete_context'] ) || ! empty( $detail['truncated'] ) || ! is_string( $current['event_code'] ) || str_starts_with( $current['event_code'], 'custom.' ) || $current['event_request_uid'] !== $row['anchor_uid'] || $current['probe_family'] !== $row['family'] ) { return null; } foreach ( array( 'probe', 'anchor' ) as $kind ) { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- This lowercase versioned source/component identifier is persisted protocol data, not a displayed WordPress name; changing case would break matching and retained records. if ( $current[ $kind . '_source' ] !== $source || 'wordpress' !== $current[ $kind . '_sensor' ] || '0' !== (string) $current[ $kind . '_partial' ] ) { return null; } } if ( 'strong' === $proof['strength'] ? $row['probe_uid'] !== $row['anchor_uid'] : ( $row['probe_uid'] === $row['anchor_uid'] || $current['probe_at'] > $current['anchor_at'] ) ) { return null; } // The original local relation always has a 900-second window, independent of the server window. $event_at = self::epoch( $current['event_at'] ); $probe_at = self::epoch( $current['probe_at'], false ); $anchor_at = self::epoch( $current['anchor_at'], false ); if ( null === $event_at || null === self::epoch( $current['event_ingested_at'] ) || null === $probe_at || null === $anchor_at || $event_at > $clock || $probe_at > $clock || $anchor_at > $clock || abs( $event_at - $anchor_at ) > 900 || abs( $event_at - $probe_at ) > 900 || ( 'weak' === $proof['strength'] && ( $probe_at > $event_at || $anchor_at - $probe_at > 900 ) ) || ! in_array( $current['probe_family'], array( 'reserved_configuration', 'source_diagnostic', 'traversal_shape', 'sql_query_shape', 'script_query_shape', 'component_recon', 'unusual_method' ), true ) ) { return null; } $basis = 'strong' === $proof['strength'] ? 'same_locally_generated_request' : 'same_exact_address_and_site_within_window'; if ( hash( 'sha256', implode( ':', array( 1, $network, $site, $row['event_uid'], $row['probe_uid'], $basis ) ) ) !== $row['proof_key'] ) { return null; } $context = array( 'network_id' => $network, 'site_id' => $site, 'case_uid' => $case_row['uid'], 'revision' => (int) $case_row['revision'], 'case_expires_at' => self::epoch( $case_row['expires_at'] ), 'request_days' => $policy['request_days'], 'source_key' => $row['source_key'], 'source_uid' => $row['connector_uid'], 'source_binding' => $row['source_binding'], 'window_seconds' => (int) $row['window_seconds'], 'late_seconds' => (int) $row['late_seconds'], 'reference_count' => $references, // Read-only replay supplies no new relation: history capacity is an admission-only concern. 'history_count' => 200, 'event' => array( 'id' => $row['event_id'], 'uid' => $row['event_uid'], 'request_uid' => $row['anchor_uid'], 'occurred_at' => $current['event_at'], 'source' => $source, 'expires_at' => self::epoch( $current['event_at'] ) + $event_days * 86400, ), 'anchor' => array( 'id' => $row['anchor_id'], 'uid' => $row['anchor_uid'], 'occurred_at' => $current['anchor_at'], 'expires_at' => self::epoch( $current['anchor_at'], false ) + $policy['request_days'] * 86400, ), 'probes' => array( array( 'id' => $row['probe_id'], 'uid' => $row['probe_uid'], 'proof_id' => $row['proof_id'], 'proof_key' => $row['proof_key'], 'family' => $current['probe_family'], 'safe_path' => $current['probe_path'], 'occurred_at' => $current['probe_at'], 'source' => $source, 'expires_at' => min( self::epoch( $proof_until ), self::epoch( $current['probe_at'], false ) + $policy['request_days'] * 86400 ), ), ), ); $result = RequestCaseServerContext::validate_retained( $context, array( $current ), array( $row ), $clock ); if ( $result['links'] || array( $row['link_key'] ) !== $result['unchanged'] || $current['captured_at'] . '.000000' !== $row['occurred_at'] || $current['ingested_at'] . '.000000' !== $row['ingested_at'] ) { return null; } $until = min( self::epoch( $row['expires_at'] ), $context['case_expires_at'], $context['event']['expires_at'], $context['anchor']['expires_at'], $context['probes'][0]['expires_at'], self::epoch( $current['captured_at'], false ) + $policy['request_days'] * 86400 ); return $until > $clock ? gmdate( 'Y-m-d H:i:s', $until ) . '.000000' : null; } catch ( \Throwable $error ) { return null; } } /** * Reproduce the finite original mutation rule using the case's immutable configuration. * * @param array $case_row Retained case and its configuration digest. * @param array $current Fresh event identity, outcome and actor projection. * @param mixed $detail Decoded event payload to validate against the finite mutation rules. * @return bool Whether the current evidence still establishes the original supported mutation. */ private static function mutation( array $case_row, array $current, mixed $detail ): bool { if ( ! is_array( $detail ) || '1' !== (string) ( $case_row['rule_version'] ?? '' ) || ! self::id( $case_row['actor_id'] ?? null ) || 'authenticated_user' !== ( $current['event_actor_kind'] ?? null ) || (string) ( $current['event_actor_id'] ?? '' ) !== (string) $case_row['actor_id'] || ! in_array( $current['event_outcome'] ?? null, array( 'completed', 'stored_change' ), true ) || ! is_string( $case_row['configuration'] ?? null ) || strlen( $case_row['configuration'] ) > 8192 || ! is_string( $case_row['configuration_hash'] ?? null ) || ! hash_equals( $case_row['configuration_hash'], hash( 'sha256', $case_row['configuration'] ) ) ) { return false; } $config = json_decode( $case_row['configuration'], true, 8 ); $keys = array( 'security_components', 'sequence_window_seconds', 'refund_window_seconds', 'refund_minimum_count', 'refund_currency', 'refund_minimum_total' ); if ( ! is_array( $config ) || count( $config ) !== 6 || array_diff( $keys, array_keys( $config ) ) || ! is_array( $config['security_components'] ) || ! array_is_list( $config['security_components'] ) || count( $config['security_components'] ) > 32 ) { return false; } foreach ( $config['security_components'] as $component ) { if ( ! is_string( $component ) || strlen( $component ) > 160 || str_contains( $component, '..' ) || ! preg_match( '~\A[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)?\.php\z~D', $component ) ) { return false; } } foreach ( array( 'sequence_window_seconds' => array( 60, 3600 ), 'refund_window_seconds' => array( 60, 3600 ), 'refund_minimum_count' => array( 2, 100 ), ) as $key => $range ) { if ( ! is_int( $config[ $key ] ) || $config[ $key ] < $range[0] || $config[ $key ] > $range[1] ) { return false; } } if ( ! is_string( $config['refund_currency'] ) || ( '' !== $config['refund_currency'] && ! preg_match( '/\A[A-Z]{3}\z/D', $config['refund_currency'] ) ) || ! is_string( $config['refund_minimum_total'] ) || ! preg_match( '/\A(?:0|[1-9][0-9]{0,11})(?:\.[0-9]{1,6})?\z/D', $config['refund_minimum_total'] ) ) { return false; } $facts = $detail['facts'] ?? array(); $code = $current['event_code'] ?? null; if ( ! is_array( $facts ) ) { return false; } if ( in_array( $code, array( 'configuration.plugin_installed', 'configuration.theme_installed' ), true ) ) { return true === ( $facts['identity_available'] ?? null ) && true === ( $facts['file_changed'] ?? null ); } if ( 'identity.roles_changed' === $code && self::id( $current['event_subject_id'] ?? null ) ) { $diffs = $detail['diffs'] ?? array(); if ( ! is_array( $diffs ) || count( $diffs ) > 100 ) { return false; } foreach ( $diffs as $diff ) { if ( is_array( $diff ) && 'roles' === ( $diff['field'] ?? null ) && 'captured' === ( $diff['availability'] ?? null ) && false === ( $diff['redacted'] ?? true ) && is_array( $diff['before'] ?? null ) && is_array( $diff['after'] ?? null ) && ! in_array( 'administrator', $diff['before'], true ) && in_array( 'administrator', $diff['after'], true ) ) { return true; } } } return ( 'configuration.option_changed' === $code && in_array( $facts['option'] ?? null, array( 'users_can_register', 'default_role', 'admin_email', 'template', 'stylesheet' ), true ) ) || ( in_array( $code, array( 'configuration.plugin_activated', 'configuration.plugin_deactivated' ), true ) && in_array( $facts['component'] ?? null, $config['security_components'], true ) ); } /** * Parse an exact UTC timestamp into positive epoch seconds. * * @param mixed $value Candidate timestamp to check. * @param bool $microseconds Whether the timestamp must include six fractional-second digits. * @return int|null Positive epoch seconds, or null when the timestamp does not match. * @throws \ValueError If the native date parser rejects a NUL byte in the input. */ private static function epoch( mixed $value, bool $microseconds = true ): ?int { $format = $microseconds ? 'Y-m-d H:i:s.u' : 'Y-m-d H:i:s'; $date = is_string( $value ) ? \DateTimeImmutable::createFromFormat( '!' . $format, $value, new \DateTimeZone( 'UTC' ) ) : false; return $date && $date->format( $format ) === $value && $date->getTimestamp() > 0 ? $date->getTimestamp() : null; } /** * Check the lowercase UUID representation used by source identities. * * @param mixed $value Candidate UUID string. * @return bool Whether the UUID has the required hexadecimal grouping. */ private static function uuid( mixed $value ): bool { return is_string( $value ) && 1 === preg_match( '/\A[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\z/D', $value ); } /** * Check the bounded positive decimal source identifier representation. * * @param mixed $value Candidate integer or decimal string. * @return bool Whether the value contains one to eighteen positive decimal digits. */ private static function id( mixed $value ): bool { return ( is_int( $value ) || is_string( $value ) ) && 1 === preg_match( '/\A[1-9][0-9]{0,17}\z/D', (string) $value ); } /** * Check a native table identifier before SQL interpolation. * * @param string $value Candidate table identifier. * @return bool Whether the identifier uses only allowed characters within the storage bound. */ private static function table( string $value ): bool { return 1 === preg_match( '/\A[a-zA-Z0-9_]{1,64}\z/D', $value ); } }