10, 'extension_assertion' => 10, 'routine_content' => 5, 'commerce_operation' => 10, 'authentication_observation' => 5, 'authentication_burst' => 25, 'credential_change' => 25, 'role_change' => 20, 'administrator_role_added' => 45, 'sensitive_configuration' => 35, 'configuration_change' => 20, 'component_configuration' => 30, 'automatic_component_maintenance' => 15, 'code_change' => 40, 'unsuccessful_operation' => 15, 'unseen_login_hour' => 5, 'claimed_account_authentication_burst' => 25, 'privileged_public_registration' => 45, 'active_high_priority_case' => 30, 'active_review_case' => 10, ); /** * Derive the shared assessment and privacy mutex from the native installation identity. * * @return string Stable installation-specific mutex name. */ public static function lock_name(): string { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return 'lai_derived_' . substr( hash( 'sha256', DB_NAME . ':' . $wpdb->base_prefix ), 0, 40 ); } /** * Resolve the fixed optional assessment table under the native installation prefix. * * @return string Owned assessment table name. */ private static function table(): string { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return $wpdb->base_prefix . 'logged_ai_pro_assessments'; } /** * Capture native pointer, identity and table-map values without invoking accessors. * * Pure drift fingerprint, not authority. Never refill native caches after a final grant check. * * @phpstan-impure * * @param \wpdb $db Native database connection selected for this operation. * * @return array Drift fingerprint that carries no independent authorization decision. */ public static function native_context( \wpdb $db ): array { $user = $GLOBALS['current_user'] ?? null; $blog = $GLOBALS['current_blog'] ?? null; $network = $GLOBALS['current_site'] ?? null; $user_values = $user instanceof \WP_User ? get_object_vars( $user ) : array(); $blog_values = $blog instanceof \WP_Site ? get_object_vars( $blog ) : array(); // WP_Network's stored ID is private; accessors may themselves query. $network_values = $network instanceof \WP_Network ? (array) $network : array(); $context = array( $GLOBALS['wpdb'] ?? null, $GLOBALS['blog_id'] ?? null, $GLOBALS['table_prefix'] ?? null, $user_values['ID'] ?? 0, array_intersect_key( $blog_values, array_fill_keys( array( 'blog_id', 'site_id', 'deleted', 'archived', 'spam' ), true ) ), $network_values["\x00WP_Network\x00id"] ?? null ); foreach ( array( 'prefix', 'base_prefix', 'blogid', 'siteid', 'posts', 'comments', 'links', 'options', 'postmeta', 'terms', 'term_taxonomy', 'term_relationships', 'termmeta', 'commentmeta', 'users', 'usermeta', 'blogs', 'blogmeta', 'signups', 'site', 'sitemeta', 'registration_log' ) as $field ) { $context[ $field ] = $db->{$field} ?? null; } return $context; } /** * Capture the current global database context for the held mutation. * * @phpstan-impure Reads current native authority or mutable context. * * @return array Current native drift fingerprint. */ private static function context(): array { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return self::native_context( $wpdb ); } /** * Inspect or release only the original native mutation mutex without wpdb retries. * * The fixed prepared native read does not invoke query filters, reconnect or * replace the caller's wpdb result cache. A release may use a still-live owned * handle even after wpdb has selected a replacement handle. * * @param bool $release Whether to release the original lock instead of checking ownership. * @return bool Whether the original thread still owns, or successfully released, its lock. */ private static function native_mutation_lock( bool $release = false ): bool { $handle = self::$mutation_handle; $thread = self::$mutation_thread; $lock = self::$mutation_lock; $statement = null; $allowed = false; try { if ( ! $handle instanceof \mysqli || null === $thread || null === $lock || $handle->thread_id !== $thread || ( ! $release && ( ! self::$connection instanceof \wpdb || self::$connection->__get( 'dbh' ) !== $handle ) ) ) { return false; } $statement = $handle->prepare( $release ? 'SELECT RELEASE_LOCK(?)' : 'SELECT IS_USED_LOCK(?)=CONNECTION_ID()' ); $owned = null; if ( $statement instanceof \mysqli_stmt && $statement->bind_param( 's', $lock ) && $statement->execute() && $statement->bind_result( $owned ) && true === $statement->fetch() ) { $allowed = 1 === (int) $owned && null === $statement->fetch() && $handle->thread_id === $thread && ( $release || self::$connection->__get( 'dbh' ) === $handle ); } } catch ( \Throwable $error ) { $allowed = false; } finally { if ( $statement instanceof \mysqli_stmt ) { try { if ( ! $statement->close() ) { $allowed = false; } } catch ( \Throwable $error ) { $allowed = false; } } } return $allowed; } /** * Recheck held connection identity, native context and optional owner authorization. * * @phpstan-impure Reads current native authority or mutable context. * * @return bool Whether the current mutation still satisfies its captured authority boundary. */ private static function allowed(): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return self::$held && self::$connection === $wpdb && self::context() === self::$origin && self::native_mutation_lock() && ( null === self::$authorize || true === ( self::$authorize )() ) && self::$connection === $wpdb && self::context() === self::$origin && self::native_mutation_lock(); } /** * Expose the current held-mutation authority check to fixed cleanup adapters. * * Fresh owner authorization for fixed optional cleanup adapters under this held mutation. * * @phpstan-impure * * @return bool Whether the owner authorization and original native context remain valid. */ public static function mutation_allowed(): bool { return self::allowed(); } /** * Run bounded-wait privacy work under one nonreentrant native mutex lease. * * No BEGIN/COMMIT: source owners retain their transaction. Worker uses this lock before its own transaction. * * @param callable $work Callback performing the selected operation inside its ownership boundary. * @param callable|null $authorize Optional callback rechecking owner authorization for the held mutation. * @param int $wait_seconds Allowed mutex wait of zero or one second. * * @return mixed Work result, or a wait, authority, interruption or lock-release error. */ public static function with_mutation( callable $work, ?callable $authorize = null, int $wait_seconds = 1 ): mixed { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! in_array( $wait_seconds, array( 0, 1 ), true ) ) { return new \WP_Error( 'derived_wait', 'Derived lock wait must be nonblocking or one second.' ); } if ( self::$entered ) { return new \WP_Error( 'derived_busy', 'Derived privacy maintenance cannot reenter its current operation.' ); } self::$entered = true; $connection = $wpdb; $locked = false; $release_failed = false; $result = null; self::$origin = self::context(); self::$authorize = $authorize; self::$connection = $connection; $lock = self::lock_name(); $old = $connection->suppress_errors( true ); try { $locked = '1' === (string) $connection->get_var( $connection->prepare( 'SELECT GET_LOCK(%s,%d)', $lock, $wait_seconds ) ); if ( ! $locked || '' !== $connection->last_error ) { $result = new \WP_Error( 'derived_busy', 'Assessment/privacy maintenance is busy or unavailable. Retry without assuming completion.' ); } else { $handle = $connection->__get( 'dbh' ); if ( ! $handle instanceof \mysqli ) { throw new \RuntimeException( 'The original mutation handle is unavailable.' ); } self::$mutation_handle = $handle; self::$mutation_thread = $handle->thread_id; self::$mutation_lock = $lock; self::$held = true; $result = self::allowed() ? $work() : new \WP_Error( 'forbidden', 'Derived privacy authority or scope changed.' ); } } catch ( \Throwable $error ) { $result = new \WP_Error( 'derived_interrupted', 'Derived privacy maintenance was interrupted. Original evidence may require a retry.' ); } finally { if ( $locked ) { $release_failed = ! self::native_mutation_lock( true ); } $connection->suppress_errors( $old ); self::$held = false; self::$entered = false; self::$origin = null; self::$authorize = null; self::$connection = null; self::$mutation_handle = null; self::$mutation_thread = null; self::$mutation_lock = null; } return $release_failed ? new \WP_Error( 'derived_lock', 'Derived maintenance may have completed, but its lock release failed. Inspect and retry.' ) : $result; } /** * Inspect optional assessment storage without treating failed reads as absence. * * A failed metadata read must never be interpreted as an absent optional table. * * @return bool|\WP_Error Whether the exact table exists, or an inspection/identity error. */ public static function present(): bool|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $old = $wpdb->suppress_errors( true ); try { $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Inspect actual optional assessment storage; cached absence could skip retained personal data. $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'derived_storage', 'Optional assessment storage could not be inspected.' ); } if ( null !== $found && $table !== $found ) { return new \WP_Error( 'derived_storage', 'Optional assessment storage identity is unavailable.' ); } return null !== $found; } finally { $wpdb->suppress_errors( $old ); } } /** * Project exact selected event identities into the fixed alert-cleanup format. * * Fixed exact source identities for the optional alert cleanup contract. * * @param array $rows Bounded selected source rows retaining their original IDs and UUIDs. * * @return array Source records containing event kind, ID, UUID and fixed revision. */ private static function alert_event_sources( array $rows ): array { return array_map( static fn( array $row ): array => array( 'kind' => 'event', 'id' => (string) $row['id'], 'uid' => $row['uid'], 'revision' => '1', ), $rows ); } /** * Complete pending alert cleanup after the source owner physically removes events. * * Called by the source owner after physical deletion, under the same held mutation. * * @param int $network Server-selected current network identifier. * @param int $site Selected site identifier, or zero for network-origin source completion. * @param array $rows Bounded selected source rows retaining their original IDs and UUIDs. * * @return array|\WP_Error Dependent completion progress or a held-scope/reference error. */ public static function complete_events( int $network, int $site, array $rows ): array|\WP_Error { if ( ! self::allowed() || Scope::network() !== $network || ( $site > 0 && get_current_blog_id() !== $site ) || count( $rows ) > 500 ) { return new \WP_Error( 'derived_scope', 'Source completion requires its held privacy scope.' ); } foreach ( $rows as $row ) { if ( ! is_array( $row ) || ! self::id( $row['id'] ?? null ) || ! self::uid( $row['uid'] ?? null ) ) { return new \WP_Error( 'derived_batch', 'Source completion needs exact identities.' ); } } return $site > 0 ? AdvancedAlertPrivacy::complete_sources( $network, $site, self::alert_event_sources( $rows ) ) : array( 'removed' => 0, 'done' => true, ); } /** * Reduce bounded dependent copies before discarding the selected source event identities. * * Delete all retained model/evaluation versions in bounded steps before deleting source joins. * * @param int $network Server-selected current network identifier. * @param int $site Selected site identifier, or zero for network-origin source evidence. * @param array $rows Bounded selected source rows retaining their original IDs and UUIDs. * @param array $operation Fixed erasure purpose and optional user subject for dependent adapters. * * @return array|\WP_Error Aggregated adapter-reported removal progress, or an explicit cleanup error. */ public static function erase_events( int $network, int $site, array $rows, array $operation = array( 'kind' => 'exact' ) ): array|\WP_Error { $operation_valid = ( array_keys( $operation ) === array( 'kind' ) && in_array( $operation['kind'], array( 'exact', 'retention' ), true ) ) || ( 2 === count( $operation ) && ! array_diff( array_keys( $operation ), array( 'kind', 'subject' ) ) && 'user' === ( $operation['kind'] ?? null ) && is_int( $operation['subject'] ?? null ) && $operation['subject'] > 0 ); if ( ! $operation_valid ) { return new \WP_Error( 'derived_operation', 'Unknown derived erasure purpose.' ); } global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( Scope::network() !== $network || ( 0 === $site ? ! is_multisite() : get_current_blog_id() !== $site ) || count( $rows ) > 500 || ! self::allowed() ) { return new \WP_Error( 'forbidden', 'Derived event cleanup requires its held scope and privacy authority.' ); } if ( ! $rows ) { if ( class_exists( AdvancedAlertCasePrivacy::class ) ) { return IncidentPrivacy::erase_events( $network, $site, $rows, $operation ); } return array( 'removed' => 0, 'done' => true, ); } $ids = array(); $uids = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) || ! self::id( $row['id'] ?? null ) || ! self::uid( $row['uid'] ?? null ) ) { return new \WP_Error( 'derived_batch', 'Derived cleanup received an invalid internal event reference.' ); } $ids[] = (string) $row['id']; $uids[] = $row['uid']; } $advanced = $site > 0 ? AdvancedAlertPrivacy::erase_sources( $network, $site, self::alert_event_sources( $rows ) ) : array( 'removed' => 0, 'done' => true, ); if ( is_wp_error( $advanced ) || ! $advanced['done'] ) { return $advanced; } $runner = class_exists( IncidentRunnerPrivacy::class ) ? IncidentRunnerPrivacy::erase_events( $network, $site, $rows ) : array( 'removed' => 0, 'done' => true, ); if ( ! is_wp_error( $runner ) ) { $runner['removed'] += $advanced['removed']; } if ( is_wp_error( $runner ) || ! $runner['done'] ) { return $runner; } $report = class_exists( ReportPrivacy::class ) ? ReportPrivacy::erase_events( $network, $site, $rows ) : array( 'removed' => 0, 'done' => true, ); if ( ! is_wp_error( $report ) ) { $report['removed'] += $runner['removed']; } if ( is_wp_error( $report ) || ! $report['done'] ) { return $report; } $incident = IncidentPrivacy::erase_events( $network, $site, $rows, $operation ); if ( ! is_wp_error( $incident ) ) { $incident['removed'] += $report['removed']; } if ( is_wp_error( $incident ) || ! $incident['done'] ) { return $incident; } $baseline = BaselinePrivacy::erase_events( $network, $site, $rows ); if ( is_wp_error( $baseline ) ) { return $baseline; } $baseline['removed'] += $incident['removed']; if ( ! $baseline['done'] ) { return $baseline; } $present = self::present(); if ( is_wp_error( $present ) ) { return $present; } if ( ! self::allowed() ) { return new \WP_Error( 'forbidden', 'Derived cleanup authority changed during storage inspection.' ); } if ( ! $present ) { return $baseline; } $marks = implode( ',', array_fill( 0, count( $ids ), '%s' ) ); // One literal %s token per validated ID and per matching UUID; bindings remain unescaped until the complete queries below. $bindings = array_merge( array( self::table(), $network, $site ), $ids, $uids ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Each marks list contains only N generated %s tokens. The complete query receives [table, network, site, N IDs, N UUIDs] once; 3+2N slots match 3+2N values. This authoritative privacy deletion/completion check cannot use cached results. $removed = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=%d AND (event_id IN ({$marks}) OR event_uid IN ({$marks})) ORDER BY id LIMIT 500", $bindings ) ); if ( false === $removed ) { return new \WP_Error( 'derived_storage', 'Linked assessments could not be erased. Original event references are retained for retry.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Each marks list contains only N generated %s tokens. The complete query receives [table, network, site, N IDs, N UUIDs] once; 3+2N slots match 3+2N values. This authoritative privacy deletion/completion check cannot use cached results. $left = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND (event_id IN ({$marks}) OR event_uid IN ({$marks})) LIMIT 1", $bindings ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'derived_storage', 'Remaining assessment cleanup could not be verified.' ); } if ( ! self::allowed() ) { return new \WP_Error( 'forbidden', 'Derived cleanup authority changed. Original event references are retained.' ); } return array( 'removed' => $removed + $baseline['removed'], 'done' => null === $left, ); } /** * Check a positive native integer or the bounded positive decimal identifier grammar. * * @param mixed $value Supplied value to inspect without narrowing its native input contract. * * @return bool Whether the value matches this internal event-reference representation. */ private static function id( mixed $value ): bool { return ( is_int( $value ) && $value > 0 ) || ( is_string( $value ) && 1 === preg_match( '/\A[1-9][0-9]{0,19}\z/D', $value ) ); } /** * Check the fixed lowercase event UUID grammar. * * @param mixed $value Supplied value to inspect without narrowing its native input contract. * * @return bool Whether the value is a supported event UUID string. */ private static function uid( mixed $value ): bool { return is_string( $value ) && 1 === preg_match( '/\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/D', $value ); } /** * Read and validate bounded retained assessment versions through current export authority. * * Protected personal export only; no scoring algorithm or unvalidated Pro formatter is loaded. * * @param Scope $scope Captured native authorization scope whose current grants are rechecked. * @param array $event Exact scoped retained event identity and metadata selected for personal export. * * @return array|\WP_Error Validated historical assessment records or explicit availability/authorization errors. */ public static function personal_projection( Scope $scope, array $event ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $cap = 0 === $scope->site_id ? 'logged_ai_export_network_events' : 'logged_ai_export_events'; if ( ! $scope->valid_for_all( array( $cap ) ) || (int) ( $event['network_id'] ?? -1 ) !== $scope->network_id || (int) ( $event['site_id'] ?? -1 ) !== $scope->site_id || ! self::id( $event['id'] ?? null ) || ! self::uid( $event['uid'] ?? null ) ) { return new \WP_Error( 'derived_scope', 'Assessment export scope is unavailable.' ); } $origin = self::context(); $present = self::present(); if ( is_wp_error( $present ) ) { return $present; } if ( self::context() !== $origin || ! $scope->valid_for_all( array( $cap ) ) ) { return new \WP_Error( 'forbidden', 'Assessment export authority changed during storage inspection.' ); } if ( ! $present ) { return array( 'contract_version' => self::VERSION, 'availability' => 'optional_storage_absent', 'records' => array(), ); } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed escaped assessment table; exact event scope and ID/UID use prepared values and fixed projection bounds. Read current authorized retained assessments; cached rows could outlive privacy erasure. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id,model_version,evaluation_version,state,score,level,CASE WHEN OCTET_LENGTH(reasons)<=4096 THEN reasons ELSE NULL END AS reasons,CASE WHEN OCTET_LENGTH(availability)<=2048 THEN availability ELSE NULL END AS availability,evaluated_at FROM %i WHERE network_id=%d AND site_id=%d AND event_id=%s AND event_uid=%s ORDER BY id LIMIT 11', self::table(), $event['network_id'], $event['site_id'], $event['id'], $event['uid'] ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $rows ) || count( $rows ) > 10 || self::context() !== $origin ) { return new \WP_Error( 'derived_storage', 'Retained assessments could not be exported within their declared scope and bounds.' ); } $clean = array(); foreach ( $rows as $row ) { $record = self::assessment( $row ); if ( is_wp_error( $record ) ) { return $record; } $clean[] = $record; } if ( ! $scope->valid_for_all( array( $cap ) ) || self::context() !== $origin ) { return new \WP_Error( 'forbidden', 'Assessment export authority changed during its read.' ); } return array( 'contract_version' => self::VERSION, 'availability' => $clean ? 'retained_assessments' : 'no_retained_assessment', 'meaning' => 'Recorded product investigation priority, not a probability of malicious activity. Source events and assessment rows are read in bounded batches, not a transactionally complete snapshot.', 'records' => $clean, ); } /** * Validate a retained assessment and format its supported privacy export fields. * * @param array $row Retained operation-specific metadata row to validate and project. * * @return array|\WP_Error Decoded historical assessment, or an incompatible retained-schema error. */ private static function assessment( array $row ): array|\WP_Error { $invalid = static fn() => new \WP_Error( 'derived_schema', 'A retained assessment does not match the supported privacy export contract. It was not silently omitted.' ); if ( ! self::id( $row['id'] ?? null ) || ! in_array( $row['model_version'] ?? null, array( 'priority-v1', 'priority-v2' ), true ) || ! is_string( $row['evaluation_version'] ?? null ) || ! ctype_digit( $row['evaluation_version'] ) || (int) $row['evaluation_version'] < 1 || (int) $row['evaluation_version'] > 10 || ! in_array( $row['state'] ?? '', array( 'evaluated', 'unavailable' ), true ) ) { return $invalid(); } $score = null; if ( 'evaluated' === $row['state'] ) { if ( ! is_string( $row['score'] ?? null ) || ! ctype_digit( $row['score'] ) || (int) $row['score'] > 100 || ! in_array( $row['level'] ?? '', array( 'low', 'review', 'high', 'critical' ), true ) ) { return $invalid(); } $score = (int) $row['score']; } elseif ( null !== $row['score'] || 'unavailable' !== $row['level'] ) { return $invalid(); } if ( ! is_string( $row['reasons'] ?? null ) || strlen( $row['reasons'] ) > 4096 || ! is_string( $row['availability'] ?? null ) || strlen( $row['availability'] ) > 2048 ) { return $invalid(); } $reasons = json_decode( $row['reasons'], true, 8 ); $availability = json_decode( $row['availability'], true, 8 ); if ( ! is_array( $reasons ) || ! array_is_list( $reasons ) || count( $reasons ) > 16 || ! is_array( $availability ) ) { return $invalid(); } if ( 'priority-v1' === $row['model_version'] ) { // Preserve the original v1 decoder and serialized result contract. foreach ( $reasons as $reason ) { if ( ! is_array( $reason ) || 2 !== count( $reason ) || ! in_array( $reason['code'] ?? '', self::REASONS, true ) || ! is_int( $reason['points'] ?? null ) || $reason['points'] < 0 || $reason['points'] > 100 ) { return $invalid(); } } $fields = array( 'ip' => array( 'not_collected_by_administrative_sensor' ), 'geography' => array( 'unavailable' ), 'reputation' => array( 'unavailable' ), 'login_hour' => array( 'not_applicable', 'insufficient_data', 'available' ), 'incident_signal' => array( 'not_implemented' ), ); if ( count( $availability ) !== count( $fields ) ) { return $invalid(); } foreach ( $fields as $field => $values ) { if ( ! in_array( $availability[ $field ] ?? null, $values, true ) ) { return $invalid(); } } } elseif ( ! self::assessment_v2( $row, $score, $reasons, $availability ) ) { return $invalid(); } $time = is_string( $row['evaluated_at'] ?? null ) ? \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s.u', $row['evaluated_at'], new \DateTimeZone( 'UTC' ) ) : false; if ( ! $time || $time->format( 'Y-m-d H:i:s.u' ) !== $row['evaluated_at'] ) { return $invalid(); } return array( 'assessment_id' => (string) $row['id'], 'model_version' => $row['model_version'], 'evaluation_version' => (int) $row['evaluation_version'], 'state' => $row['state'], 'score' => $score, 'level' => $row['level'], 'reasons' => $reasons, 'availability' => $availability, 'evaluated_at_utc' => $row['evaluated_at'], ); } /** * Validate fixed version-two reasons, context and score relationships without scoring. * * Fixed v2 privacy format; validation does not load or run an optional scoring engine. * * @param array $row Retained operation-specific metadata row to validate and project. * @param int|null $score Stored validated numeric assessment score, or null when unavailable. * @param array $reasons Decoded retained assessment reasons to validate. * @param array $availability Decoded retained assessment availability and context fields. * * @return bool Whether the stored version-two assessment satisfies its privacy format. */ private static function assessment_v2( array $row, ?int $score, array $reasons, array $availability ): bool { $fields = array( 'ip' => array( 'not_collected_by_administrative_sensor' ), 'geography' => array( 'unavailable' ), 'reputation' => array( 'unavailable' ), 'login_hour' => array( 'not_applicable', 'insufficient_data', 'available' ), 'incident_signal' => array( 'not_applicable', 'unavailable', 'no_active_case', 'active_case' ), 'baseline' => array( 'not_applicable', 'not_built', 'unavailable', 'needs_rebuild', 'ready', 'insufficient_data', 'incomplete' ), ); if ( 7 !== count( $availability ) || ! array_key_exists( 'context', $availability ) || count( $reasons ) > 3 ) { return false; } foreach ( $fields as $field => $values ) { if ( ! in_array( $availability[ $field ] ?? null, $values, true ) ) { return false; } } $context = $availability['context']; if ( ! is_array( $context ) || 3 !== count( $context ) || 1 !== ( $context['version'] ?? null ) || ! is_string( $context['binding'] ?? null ) || 1 !== preg_match( '/\A[a-f0-9]{64}\z/D', $context['binding'] ) || ! array_key_exists( 'baseline', $context ) ) { return false; } if ( null !== $context['baseline'] ) { $baseline = $context['baseline']; if ( ! is_array( $baseline ) || 4 !== count( $baseline ) || ! self::uid( $baseline['instance_uid'] ?? null ) ) { return false; } foreach ( array( 'source_generation', 'control_revision' ) as $field ) { $value = $baseline[ $field ] ?? null; if ( ! is_string( $value ) || ! self::id( $value ) || ( 20 === strlen( $value ) && strcmp( $value, '18446744073709551615' ) > 0 ) ) { return false; } } $date = is_string( $baseline['as_of'] ?? null ) ? \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s.u', $baseline['as_of'], new \DateTimeZone( 'UTC' ) ) : false; if ( ! $date || $date->format( 'Y-m-d H:i:s.u' ) !== $baseline['as_of'] ) { return false; } } if ( 'unavailable' === $row['state'] ) { return array() === $reasons; } if ( ! $reasons ) { return false; } $seen = array(); $sum = 0; foreach ( $reasons as $index => $reason ) { $code = $reason['code'] ?? null; if ( ! is_array( $reason ) || 2 !== count( $reason ) || ! is_string( $code ) || ! isset( self::V2_WEIGHTS[ $code ] ) || isset( $seen[ $code ] ) || ( $reason['points'] ?? null ) !== self::V2_WEIGHTS[ $code ] ) { return false; } $seen[ $code ] = true; $sum += $reason['points']; $hour = 'unseen_login_hour' === $code; $case = in_array( $code, array( 'active_high_priority_case', 'active_review_case' ), true ); if ( 0 === $index ) { if ( $hour || $case ) { return false; } continue; } if ( $hour ) { if ( 1 !== $index || 'authentication_observation' !== $reasons[0]['code'] || 'available' !== $availability['login_hour'] || 'ready' !== $availability['baseline'] || null === $context['baseline'] ) { return false; } } elseif ( ! $case || count( $reasons ) - 1 !== $index || 'active_case' !== $availability['incident_signal'] ) { return false; } } $expected = min( 75, $sum ); $level = $expected >= 75 ? 'critical' : ( $expected >= 50 ? 'high' : ( $expected >= 25 ? 'review' : 'low' ) ); return $expected === $score && $level === $row['level']; } /** * Remove the fixed assessment table under the already confirmed lifecycle mutation. * * Caller has already confirmed installation-wide deletion and must retain this mutex through Free table removal. * * @return true|\WP_Error True after removal or absence, or a held-context/storage error. */ public static function uninstall_owned_table(): bool|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! self::allowed() ) { return new \WP_Error( 'derived_busy', 'Assessment uninstall requires its held lifecycle lock.' ); } $present = self::present(); if ( is_wp_error( $present ) ) { return $present; } if ( ! self::allowed() ) { return new \WP_Error( 'derived_scope', 'Assessment uninstall context changed.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Only the fixed assessment table is dropped; quoted_table() escapes the native-prefix identifier. Drop only the actual owned assessment table under confirmed held uninstall authority. Fixed owned schema removal is intentional during authorized native uninstall. if ( $present && false === $wpdb->query( $wpdb->prepare( 'DROP TABLE %i', self::table() ) ) ) { return new \WP_Error( 'derived_storage', 'Assessment data could not be removed. Keep original lifecycle metadata for retry.' ); } return true; } }