base_prefix . 'logged_ai_' . $kind; } /** * Read the current network and site identifiers. * * @return array Current network identifier followed by current site identifier. */ public static function scope_ids(): array { return array( Scope::network(), (int) get_current_blog_id() ); } /** * Create or validate request storage while holding the admission mutex. * * Activation only; no DDL, transaction commit or schema repair in capture. * * @return void * * @throws \RuntimeException When activation cannot acquire its mutex or verify request storage. */ public static function activate(): void { $result = self::locked( static function (): bool { self::activate_schema(); return true; } ); if ( is_wp_error( $result ) ) { throw new \RuntimeException( 'Logged.ai request schema is busy; retry activation.' ); } } /** * Install and verify request tables before advancing their schema state. * * @return void * * @throws \RuntimeException When the required tables, indexes or storage engine cannot be verified. */ private static function activate_schema(): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $collate = $wpdb->get_charset_collate(); $schemas = array( 'requests' => "id bigint unsigned NOT NULL AUTO_INCREMENT,\n request_uid char(36) NOT NULL,\n network_id bigint unsigned NOT NULL,\n site_id bigint unsigned NOT NULL,\n captured_at datetime NOT NULL,\n ingested_at datetime NOT NULL,\n response_at datetime NULL,\n sensor varchar(24) NOT NULL,\n source varchar(24) NOT NULL,\n parser_version varchar(40) NOT NULL,\n family varchar(48) NOT NULL,\n rule_id varchar(64) NOT NULL,\n rule_revision int unsigned NOT NULL,\n safe_path varchar(100) NOT NULL,\n method varchar(12) NOT NULL,\n reason varchar(160) NOT NULL,\n confidence varchar(24) NOT NULL,\n priority varchar(24) NOT NULL,\n partial tinyint unsigned NOT NULL DEFAULT 0,\n evidence varchar(4096) NOT NULL DEFAULT '{}',\n group_key char(64) NULL,\n client_mask varchar(48) NULL,\n ip_mode varchar(12) NOT NULL,\n ip_expires_at bigint unsigned NULL,\n source_derivation varchar(24) NOT NULL DEFAULT 'direct_peer',\n source_quality varchar(24) NOT NULL DEFAULT 'legacy_peer',\n source_generation char(32) NOT NULL DEFAULT '00000000000000000000000000000000',\n alert_revision bigint unsigned NOT NULL DEFAULT 0,\n alert_state varchar(16) NOT NULL DEFAULT 'skipped',\n status smallint unsigned NULL,\n own_response varchar(32) NOT NULL,\n policy_id char(32) NULL,\n policy_revision int unsigned NULL,\n effect varchar(24) NOT NULL,\n PRIMARY KEY (id),\n UNIQUE KEY request_uid (network_id,site_id,request_uid),\n KEY scope_time (network_id,site_id,captured_at,id),\n KEY alerts_pending (network_id,site_id,alert_state,id),\n KEY peer_expiry (network_id,site_id,ip_mode,ip_expires_at,id),\n KEY family_time (network_id,site_id,family,captured_at)", 'request_groups' => "group_key char(64) NOT NULL,\n network_id bigint unsigned NOT NULL,\n site_id bigint unsigned NOT NULL,\n window_start bigint unsigned NOT NULL,\n source varchar(24) NOT NULL,\n family varchar(48) NOT NULL,\n first_at datetime NOT NULL,\n last_at datetime NOT NULL,\n admitted_count bigint unsigned NOT NULL DEFAULT 0,\n partial_count bigint unsigned NOT NULL DEFAULT 0,\n unknown_count bigint unsigned NOT NULL DEFAULT 0,\n status_1xx bigint unsigned NOT NULL DEFAULT 0,\n status_2xx bigint unsigned NOT NULL DEFAULT 0,\n status_3xx bigint unsigned NOT NULL DEFAULT 0,\n status_4xx bigint unsigned NOT NULL DEFAULT 0,\n status_5xx bigint unsigned NOT NULL DEFAULT 0,\n own_responses bigint unsigned NOT NULL DEFAULT 0,\n sample_ids varchar(160) NOT NULL DEFAULT '[]',\n PRIMARY KEY (group_key),\n KEY scope_window (network_id,site_id,window_start),\n KEY scope_family (network_id,site_id,family,window_start)", 'request_limits' => 'network_id bigint unsigned NOT NULL, site_id bigint unsigned NOT NULL, minute_bucket bigint unsigned NOT NULL DEFAULT 0, minute_used int unsigned NOT NULL DEFAULT 0, day_bucket bigint unsigned NOT NULL DEFAULT 0, day_used int unsigned NOT NULL DEFAULT 0, retained_reserved int unsigned NOT NULL DEFAULT 0, groups_reserved int unsigned NOT NULL DEFAULT 0, dropped bigint unsigned NOT NULL DEFAULT 0, last_drop bigint unsigned NOT NULL DEFAULT 0, PRIMARY KEY (network_id,site_id)', 'response_state' => "network_id bigint unsigned NOT NULL,\n site_id bigint unsigned NOT NULL,\n schema_version int unsigned NOT NULL,\n enabled tinyint unsigned NOT NULL DEFAULT 0,\n revision bigint unsigned NOT NULL DEFAULT 1,\n degraded varchar(80) NOT NULL DEFAULT '',\n last_cleanup bigint unsigned NOT NULL DEFAULT 0,\n last_change bigint unsigned NOT NULL,\n PRIMARY KEY (network_id,site_id)", 'response_policies' => "id char(32) NOT NULL,\n network_id bigint unsigned NOT NULL,\n site_id bigint unsigned NOT NULL,\n creator bigint unsigned NOT NULL,\n state varchar(16) NOT NULL,\n origin varchar(16) NOT NULL,\n revision int unsigned NOT NULL,\n parser_version varchar(40) NOT NULL,\n safe_path varchar(100) NOT NULL,\n methods varchar(24) NOT NULL,\n ttl int unsigned NOT NULL,\n compiled_hash char(64) NOT NULL,\n target_kind varchar(16) NOT NULL DEFAULT 'legacy_path',\n target_json varchar(2048) NULL,\n target_erased tinyint unsigned NOT NULL DEFAULT 0,\n target_expires_at bigint unsigned NOT NULL DEFAULT 0,\n target_captured_at bigint unsigned NOT NULL DEFAULT 0,\n target_settings_revision bigint unsigned NOT NULL DEFAULT 0,\n target_source_generation char(32) NOT NULL DEFAULT '00000000000000000000000000000000',\n source_request_uid char(36) NULL,\n evidence_id bigint unsigned NOT NULL,\n created_at bigint unsigned NOT NULL,\n starts_at bigint unsigned NOT NULL DEFAULT 0,\n expires_at bigint unsigned NOT NULL,\n changed_at bigint unsigned NOT NULL,\n PRIMARY KEY (id),\n KEY scope_state (network_id,site_id,state,expires_at),\n KEY scope_personal (network_id,site_id,target_kind,target_erased,target_expires_at),\n KEY scope_source_request (network_id,site_id,source_request_uid),\n KEY scope_creator (network_id,site_id,creator,state)", 'response_history' => 'id bigint unsigned NOT NULL AUTO_INCREMENT, network_id bigint unsigned NOT NULL, site_id bigint unsigned NOT NULL, policy_id char(32) NULL, actor_id bigint unsigned NOT NULL, action varchar(40) NOT NULL, happened_at bigint unsigned NOT NULL, revision bigint unsigned NOT NULL, PRIMARY KEY (id), KEY scope_time (network_id,site_id,happened_at,id)', ); foreach ( $schemas as $kind => $body ) { dbDelta( 'CREATE TABLE ' . self::table( $kind ) . " ({$body}) ENGINE=InnoDB {$collate};" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify physical request columns after installation; cached declarations cannot establish schema readiness. $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', self::table( $kind ) ) ); /** * Validate column names before checking the installed request schema from the untyped wpdb adapter. * * @var mixed $columns */ preg_match_all( '/^\s*([a-z_]+) (?:bigint|int|tinyint|smallint|char|varchar|datetime)\b/m', $body, $expected ); if ( ! is_array( $columns ) || array_diff( $expected[1], $columns ) ) { throw new \RuntimeException( 'Logged.ai request schema could not be validated.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify actual request indexes after installation; cached metadata cannot prove durable uniqueness. $indexes = $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i', self::table( $kind ) ), ARRAY_A ); preg_match_all( '/(?:UNIQUE )?KEY ([a-z_]+)/', $body, $keys ); if ( ! is_array( $indexes ) || array_diff( array_merge( array( 'PRIMARY' ), $keys[1] ), array_column( $indexes, 'Key_name' ) ) ) { throw new \RuntimeException( 'Logged.ai request indexes could not be validated.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify the actual request-table engine before accepting installation. $status = $wpdb->get_row( $wpdb->prepare( 'SHOW TABLE STATUS LIKE %s', $wpdb->esc_like( self::table( $kind ) ) ), ARRAY_A ); if ( ! $status || 'INNODB' !== strtoupper( (string) $status['Engine'] ) ) { throw new \RuntimeException( 'Logged.ai request tables require the InnoDB engine.' ); } } [$network, $site] = self::scope_ids(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist initial scoped response state without replacing existing configuration. $ok = $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (network_id,site_id,schema_version,last_change) VALUES (%d,%d,%d,%d)', self::table( 'response_state' ), $network, $site, self::VERSION, time() ) ); if ( false === $ok ) { throw new \RuntimeException( 'Logged.ai response state could not be initialized.' ); } // Only advance the additive schema after every table/index/engine was validated. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Advance the actual response schema version only after all physical checks succeed. if ( false === $wpdb->query( $wpdb->prepare( 'UPDATE %i SET schema_version=%d WHERE network_id=%d AND site_id=%d AND schema_version IN (1,2,3,4,5)', self::table( 'response_state' ), self::VERSION, $network, $site ) ) ) { throw new \RuntimeException( 'Logged.ai request schema version could not be finalized.' ); } if ( ! wp_next_scheduled( 'logged_ai_request_retention' ) ) { wp_schedule_event( time() + 60, 'hourly', 'logged_ai_request_retention' ); } } /** * Register observation, final-status, retention and administration hooks. * * @return void */ public static function boot(): void { add_action( 'init', array( self::class, 'observe' ), -999 ); add_filter( 'status_header', array( self::class, 'status_header' ), PHP_INT_MAX, 2 ); add_action( 'logged_ai_request_retention', array( self::class, 'retention_cron' ) ); require_once __DIR__ . '/class-requestsadmin.php'; RequestsAdmin::boot(); } /** * Remember a valid native response status without rewriting its header. * * @param string $header Native status header to return unchanged. * @param int $code Native HTTP status code to remember when within 100 through 599. * * @return string The original status header. */ public static function status_header( string $header, int $code ): string { if ( $code >= 100 && $code <= 599 ) { self::$reported_status = $code; } return $header; } /** * Classify literal reserved resources using the frozen manual-policy parser. * * Frozen v1 manual-policy parser. Broader detection never widens existing policies. * * @param string $target Request target inspected transiently without persisting raw contents. * * @return array Bounded path, eligibility and partial-analysis facts. */ public static function parse( string $target ): array { $partial = strlen( $target ) > 16384; $target = substr( $target, 0, 16384 ); $boundary = strpos( $target, '?' ); $path = false === $boundary ? $target : substr( $target, 0, $boundary ); $query_length = false === $boundary ? 0 : strlen( $target ) - $boundary - 1; $partial = $partial || strlen( $path ) > 8192 || $query_length > 8192; $path = substr( $path, 0, 8192 ); $ambiguous = $partial || '' === $path || '/' !== $path[0] || preg_match( '/[\x00-\x20\x7f\\\\#]/', $path ) || str_contains( $path, '//' ) || preg_match( '~(?:^|/)\.{1,2}(?:/|$)~', $path ) || preg_match( '/%(?![0-9A-Fa-f]{2})/', $path ); // Encoding is observation-only, even when a single decoding yields a known template. $decoded = rawurldecode( $path ); $encoded = str_contains( $path, '%' ); $template = in_array( $path, self::PATHS, true ) ? $path : ( in_array( $decoded, self::PATHS, true ) ? $decoded : null ); return array( 'safe_path' => $template, 'eligible' => ! $ambiguous && ! $encoded && null !== $template, 'partial' => (bool) ( $partial || $ambiguous || $encoded ), 'reason' => $encoded ? 'Reserved configuration resource in one decoded view; application decoding is unknown.' : 'Exact reserved configuration resource or recognized backup filename.', ); } /** * Reduce a valid supplied address to an IPv4 /24 or IPv6 /48 mask. * * Direct peer only. Forwarded and inbound correlation headers are intentionally ignored. * * @param string $peer Directly supplied address to validate and mask. * * @return string|null Masked address, or null when the supplied address is invalid. */ public static function mask_peer( string $peer ): ?string { if ( strlen( $peer ) > 45 || ! filter_var( $peer, FILTER_VALIDATE_IP ) ) { return null; } $binary = inet_pton( $peer ); if ( false === $binary ) { return null; } return 4 === strlen( $binary ) ? inet_ntop( substr( $binary, 0, 3 ) . "\x00" ) . '/24' : inet_ntop( substr( $binary, 0, 6 ) . str_repeat( "\x00", 10 ) ) . '/48'; } /** * Read current privacy settings and the source-configuration generation. * * @return array Current privacy settings, with a null generation when unavailable. */ public static function privacy_settings(): array { $settings = class_exists( Settings::class ) ? Settings::get() : array(); $source = RequestsSource::get(); return array_merge( array( 'ip_mode' => 'masked', 'exact_ip_days' => 14, 'request_days' => 7, 'request_group_days' => 30, 'policy_history_days' => 30, ), $settings, array( 'source_generation' => is_wp_error( $source ) ? null : $source['generation'] ) ); } /** * Derive only the source-address context allowed by current privacy. * * @return array Minimized address, mode, expiry and source-quality fields. */ private static function peer_context(): array { $settings = self::privacy_settings(); $source = RequestsSource::current(); $mode = in_array( $settings['ip_mode'], array( 'masked', 'full', 'disabled' ), true ) ? $settings['ip_mode'] : 'disabled'; $peer = 'disabled' === $mode ? null : $source['address']; return array( null === $peer ? null : ( 'full' === $mode ? $peer : self::mask_peer( $peer ) ), $mode, null !== $peer && 'full' === $mode ? time() + max( 1, (int) $settings['exact_ip_days'] ) * DAY_IN_SECONDS : null, $source ); } /** * Remove or reduce stored address fields according to current privacy. * * Storage cleanup uses privacy only, never the current viewer's grants. * * @param array $rows Rows to select, minimize or reduce for this operation. * * @return array Rows with unavailable address context removed or masked. */ private static function minimize_rows( array $rows ): array { $settings = self::privacy_settings(); $mode = $settings['ip_mode']; $cutoff = gmdate( 'Y-m-d H:i:s', time() - max( 1, (int) $settings['exact_ip_days'] ) * DAY_IN_SECONDS ); foreach ( $rows as &$row ) { $expired = 'full' === $row['ip_mode'] && ( empty( $row['ip_expires_at'] ) || (int) $row['ip_expires_at'] <= time() || ( $row['captured_at'] ?? '' ) < $cutoff ); if ( 'disabled' === $mode || 'disabled' === $row['ip_mode'] || $expired ) { $row['client_mask'] = null; $row['ip_mode'] = 'disabled'; $row['ip_expires_at'] = null; $row['client_context_reason'] = $expired ? 'exact_context_expired' : 'privacy_disabled'; } elseif ( 'full' !== $mode && 'full' === $row['ip_mode'] ) { $row['client_mask'] = null === $row['client_mask'] ? null : self::mask_peer( $row['client_mask'] ); $row['ip_mode'] = 'masked'; $row['ip_expires_at'] = null; $row['client_context_reason'] = 'privacy_masked'; } } return $rows; } /** * Check a fresh sensitive-context grant in the selected current namespace. * * Fresh explicit grant in the actual current site; no OS/super-admin raw-context bypass. * * @phpstan-impure * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return bool Whether the current principal retains the explicit grant in this scope. */ public static function sensitive_allowed( int $network, int $site ): bool { return Scope::network() === $network && get_current_blog_id() === $site && Scope::can( 'logged_ai_view_sensitive_context' ) && Scope::network() === $network && get_current_blog_id() === $site; } /** * Project request rows through current scope, privacy and viewer permissions. * * Reapply immediately before HTML/JSON serialization; never pass this to physical cleanup. * * @param array $rows Rows to select, minimize or reduce for this operation. * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return array Selected rows with unavailable address context hidden. */ public static function for_viewer( array $rows, int $network, int $site ): array { $principal = get_current_user_id(); $rows = array_values( array_filter( $rows, static fn( array $row ): bool => (int) ( $row['network_id'] ?? 0 ) === $network && (int) ( $row['site_id'] ?? 0 ) === $site ) ); $rows = self::minimize_rows( $rows ); $sensitive = self::sensitive_allowed( $network, $site ) && get_current_user_id() === $principal; foreach ( $rows as &$row ) { if ( ! $sensitive && 'full' === $row['ip_mode'] ) { $row['client_mask'] = null === $row['client_mask'] ? null : self::mask_peer( $row['client_mask'] ); $row['ip_mode'] = 'masked'; $row['ip_expires_at'] = null; $row['client_context_reason'] = 'sensitive_permission_required'; } $row['client_context_reason'] ??= null === $row['client_mask'] ? 'unavailable' : ( 'masked' === $row['ip_mode'] ? 'stored_masked' : 'available' ); } return $rows; } /** * Project scoped request rows after current read and sensitive-context decisions. * * @param Scope $scope Operation scope whose current permissions are checked. * @param array $rows Rows to select, minimize or reduce for this operation. * @param string $capability Read capability required from the supplied scope. * * @return array|\WP_Error Minimized rows, or an error when the requested read authority is unavailable. */ public static function for_scope( Scope $scope, array $rows, string $capability = 'logged_ai_view_events' ): array|\WP_Error { $rows = array_values( array_filter( $rows, static fn( array $row ): bool => (int) ( $row['network_id'] ?? 0 ) === $scope->network_id && (int) ( $row['site_id'] ?? 0 ) === $scope->site_id ) ); $rows = self::minimize_rows( $rows ); $exact = $scope->valid_for_all( array( $capability, 'logged_ai_view_sensitive_context' ) ); if ( ! $exact && ! $scope->valid_for( $capability ) ) { return new \WP_Error( 'forbidden', 'Request evidence authority changed during projection.' ); } // This reduction performs no further permission/settings reads after the captured decision. foreach ( $rows as &$row ) { if ( ! $exact && 'full' === $row['ip_mode'] ) { $row['client_mask'] = null === $row['client_mask'] ? null : self::mask_peer( $row['client_mask'] ); $row['ip_mode'] = 'masked'; $row['ip_expires_at'] = null; $row['client_context_reason'] = 'sensitive_permission_required'; } $row['client_context_reason'] ??= null === $row['client_mask'] ? 'unavailable' : ( 'masked' === $row['ip_mode'] ? 'stored_masked' : 'available' ); } return $rows; } /** * Hide personal policy targets that the current viewer may not inspect. * * Permission hiding does not rewrite legitimate saved state, expiry or target-erasure facts. * * @param array $rows Rows to select, minimize or reduce for this operation. * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return array Selected policies with unavailable personal target fields hidden. */ public static function policies_for_viewer( array $rows, int $network, int $site ): array { $rows = array_values( array_filter( $rows, static fn( array $row ): bool => (int) ( $row['network_id'] ?? 0 ) === $network && (int) ( $row['site_id'] ?? 0 ) === $site ) ); $sensitive = self::sensitive_allowed( $network, $site ); foreach ( $rows as &$row ) { if ( ! $sensitive && 'ip' === ( $row['target_kind'] ?? '' ) && ! empty( $row['target_json'] ) ) { $row['target_json'] = null; $row['compiled_hash'] = ''; $row['source_request_uid'] = null; $row['target_context_reason'] = 'sensitive_permission_required'; } } return $rows; } /** * Project policy rows after current scoped read and sensitive-context decisions. * * @param Scope $scope Operation scope whose current permissions are checked. * @param array $rows Rows to select, minimize or reduce for this operation. * @param string $capability Read capability required from the supplied scope. * * @return array|\WP_Error Visible policy fields, or an error when read authority is unavailable. */ public static function policies_for_scope( Scope $scope, array $rows, string $capability = 'logged_ai_manage_response' ): array|\WP_Error { $rows = array_values( array_filter( $rows, static fn( array $row ): bool => (int) ( $row['network_id'] ?? 0 ) === $scope->network_id && (int) ( $row['site_id'] ?? 0 ) === $scope->site_id ) ); $exact = $scope->valid_for_all( array( $capability, 'logged_ai_view_sensitive_context' ) ); if ( ! $exact && ! $scope->valid_for( $capability ) ) { return new \WP_Error( 'forbidden', 'Policy evidence authority changed during projection.' ); } if ( ! $exact ) { foreach ( $rows as &$row ) { if ( 'ip' === ( $row['target_kind'] ?? '' ) && ! empty( $row['target_json'] ) ) { $row['target_json'] = null; $row['compiled_hash'] = ''; $row['source_request_uid'] = null; $row['target_context_reason'] = 'sensitive_permission_required'; } } } return $rows; } /** * Require response authority and the additional grant for exact-source targets. * * @phpstan-impure Reads current native authority or mutable context. * * @param Scope $scope Operation scope whose current permissions are checked. * @param array $policy Stored or compiled policy fields being inspected. * * @return bool Whether the scope currently authorizes this policy kind. */ private static function policy_authority( Scope $scope, array $policy ): bool { return 'ip' === ( $policy['target_kind'] ?? '' ) ? $scope->valid_for_all( array( 'logged_ai_manage_response', 'logged_ai_view_sensitive_context' ) ) : $scope->valid_for( 'logged_ai_manage_response' ); } /** * Check for a retained active personal policy whose expiry is still future. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return bool|\WP_Error Whether a matching policy exists, or an error when storage is unavailable. */ private static function live_personal( int $network, int $site ): bool|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current unexpired personal policies before resuming response; cached absence could bypass required authority. $personal = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND target_kind='ip' AND state='active' AND expires_at>%d AND target_erased=0 LIMIT 1", self::table( 'response_policies' ), $network, $site, time() ) ); return '' === $wpdb->last_error ? null !== $personal : new \WP_Error( 'storage', 'Active personal policy state is unavailable.' ); } /** * Check the authority needed to resume currently retained personal policies. * * Enabling the shared switch must not resume retained IP policies without their authority. * * @phpstan-impure * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param Scope|null $scope Response-authority scope; null provides no grant to resume personal policies. * * @return bool Whether the shared response switch may be enabled in this scope. */ private static function enabling_allowed( int $network, int $site, ?Scope $scope ): bool { $state = self::state( $network, $site ); if ( null === $state ) { return false; } if ( 1 === (int) $state['enabled'] ) { return true; } $personal = self::live_personal( $network, $site ); return ! is_wp_error( $personal ) && ( ! $personal || ( null !== $scope && $scope->valid_for_all( array( 'logged_ai_manage_response', 'logged_ai_view_sensitive_context' ) ) ) ); } /** * Admit minimized request findings and apply eligible temporary manual responses. * * @return void */ public static function observe(): void { if ( class_exists( Migrations::class ) && ! Migrations::ready( 'requests' ) ) { return; } if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { // @phpstan-ignore phpstanWP.wpConstant.fetch (Use the native cron entry flag so a filtered wp_doing_cron value cannot reclassify scheduler work as a client request.) return; } try { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Validate the type, then remove one bounded WordPress slash layer before the strict request parsers. $target = $_SERVER['REQUEST_URI'] ?? ''; if ( ! is_string( $target ) ) { return; } // WordPress slashes server values during bootstrap; undo exactly that layer before parsing (two escaped bytes per original byte, plus an overflow sentinel). $target = wp_unslash( substr( $target, 0, 32770 ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Accept only the fixed native method allowlist; all other values become the finite OTHER marker. $method = $_SERVER['REQUEST_METHOD'] ?? ''; $method = is_string( $method ) && in_array( $method, array( 'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE', 'TRACK', 'CONNECT' ), true ) ? $method : 'OTHER'; $self_test = class_exists( RequestsSelfTest::class ) && RequestsSelfTest::candidate(); $analysis = RequestsClassifier::analyze( $target, $method ); if ( ! $self_test ) { $analysis = RequestsDetectors::append( $analysis, $target, $method ); } if ( $self_test ) { $analysis['matches'] = array( array( 'family' => 'observer_selftest', 'rule_id' => 'authenticated-observer-test', 'component' => 'sensor', 'safe_template' => 'authenticated_observer_test', 'view' => 'controlled_fixture', ), ); } $parsed = self::parse( $target ); [$peer, $ip_mode, $ip_expires, $source_context] = self::peer_context(); [$network, $site] = self::scope_ids(); $policy = null; $observation = null; if ( in_array( $method, array( 'GET', 'HEAD' ), true ) && ! headers_sent() && ! self::management_recovery( $target ) ) { $policy = self::matching_policy( $network, $site, $parsed['eligible'] ? $parsed['safe_path'] : '', $target, $method ); // Observation never masks an eligible active response and needs no response-switch consent. if ( ! $policy ) { $observation = self::matching_policy( $network, $site, $parsed['eligible'] ? $parsed['safe_path'] : '', $target, $method, true ); } } $matched_policy = $policy ?? $observation; if ( ! $analysis['matches'] && ! $matched_policy ) { return; } if ( self::$suppressed_policies ) { $analysis['response_exceptions'] = array_slice( self::$suppressed_policies, 0, 8 ); } if ( $observation ) { $analysis['observation_policy'] = array( 'policy_id' => $observation['id'], 'revision' => (int) $observation['revision'], ); } if ( ! $analysis['matches'] ) { // A deliberate manual match is not a security classification or proof of intent. $analysis['matches'] = array( array( 'family' => 'manual_response', 'rule_id' => 'manual-condition', 'component' => 'policy', 'safe_template' => 'manual_policy_condition', 'view' => 'validated_policy', ), ); } $match = $analysis['matches'][0]; $rule = RequestsClassifier::rules()[ $match['family'] ] ?? array( 'id' => 'manual-condition', 'revision' => 1, 'reason' => 'A deliberately configured manual condition matched; this is a response decision, not an attack finding.', 'confidence' => 'not_applicable', 'priority' => 'informational', ); if ( $observation && 'manual_response' === $match['family'] ) { $rule['reason'] = 'An observation-only manual condition matched. This policy recorded the match without denying the request; no attack classification is inferred.'; } if ( 'custom_rule' === $match['family'] ) { $rule = array( 'id' => $match['rule_id'], 'revision' => $match['revision'], 'reason' => 'An explicitly enabled operator-defined observation rule matched its bounded condition.', 'confidence' => 'operator_condition', 'priority' => $match['priority'], ); } if ( $self_test ) { $rule = array( 'id' => 'authenticated-observer-test', 'revision' => 1, 'reason' => 'A controlled authenticated routed observer test; other request coverage remains unknown.', 'confidence' => 'not_applicable', 'priority' => 'informational', ); } $record = array( 'request_uid' => Store::request_id(), 'network_id' => $network, 'site_id' => $site, 'captured_at' => gmdate( 'Y-m-d H:i:s' ), 'ingested_at' => gmdate( 'Y-m-d H:i:s' ), 'sensor' => 'wordpress', 'source' => $self_test || ( defined( 'LOGGED_AI_SYNTHETIC_TEST_ENVIRONMENT' ) && true === LOGGED_AI_SYNTHETIC_TEST_ENVIRONMENT ) ? 'synthetic_test' : 'wordpress', 'parser_version' => RequestsClassifier::VERSION, 'family' => $match['family'], 'rule_id' => $rule['id'], 'rule_revision' => $rule['revision'], 'safe_path' => $match['safe_template'], 'method' => $method, 'reason' => $rule['reason'], 'confidence' => $rule['confidence'], 'priority' => $rule['priority'], 'partial' => $analysis['partial'] ? 1 : 0, 'evidence' => wp_json_encode( $analysis ), 'client_mask' => $peer, 'ip_mode' => $ip_mode, 'ip_expires_at' => $ip_expires, 'source_derivation' => $source_context['derivation'], 'source_quality' => $source_context['quality'], 'source_generation' => $source_context['generation'], 'own_response' => 'none', 'policy_id' => $matched_policy['id'] ?? null, 'policy_revision' => $matched_policy ? (int) $matched_policy['revision'] : null, 'effect' => 'unknown', ); if ( class_exists( \LoggedAI\Pro\AutomaticResponse::class, false ) ) { $record = \LoggedAI\Pro\AutomaticResponse::capture_fields( $record ); } $record['alert_revision'] = class_exists( Alerts::class ) ? Alerts::request_capture_revision( $record ) : 0; $record['alert_state'] = $record['alert_revision'] > 0 ? 'pending' : 'skipped'; $id = self::store( $record ); if ( $id && $record['alert_revision'] > 0 ) { Alerts::schedule_for_event( $record ); } if ( $self_test ) { RequestsSelfTest::complete( $id, $source_context ); } if ( $id ) { self::$pending = array( $id, $network, $site, $record['auto_generation'] ?? null ); register_shutdown_function( array( self::class, 'finish' ) ); } if ( $policy ) { if ( ! $id ) { self::degraded( $network, $site, 'response_evidence_gap' ); } // Emit our exact status without allowing an unrelated status_header filter // to substitute a different code while retaining an "issued 403" claim. http_response_code( 403 ); self::$reported_status = 403; header( 'Cache-Control: no-store, private, max-age=0' ); header( 'Content-Type: text/plain; charset=UTF-8' ); header( 'X-Content-Type-Options: nosniff' ); if ( $id ) { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist the actual issued response fact in its admitted scoped request row. if ( 1 !== $wpdb->update( self::table( 'requests' ), array( 'own_response' => 'wordpress_response_issued', 'status' => 403, ), array( 'id' => $id, 'network_id' => $network, 'site_id' => $site, ) ) ) { self::degraded( $network, $site, 'response_evidence_gap' ); } } if ( 'HEAD' !== $method ) { echo ( esc_html__( 'Request denied by a temporary local policy. ', 'logged-ai' ) ); } exit; } } catch ( \Throwable $error ) { // Fail open. Exception content may contain request/database data and is not persisted. [$network, $site] = self::scope_ids(); self::degraded( $network, $site, 'observer_or_guard_unavailable' ); } } /** * Recognize the authenticated operator recovery screen in the current request. * * A verified operator can reach only this plugin's recovery screen. No namespace bypass. * * @param string $target Request target inspected transiently without persisting raw contents. * * @return bool Whether this request qualifies for the plugin management recovery path. */ private static function management_recovery( string $target ): bool { $path = explode( '?', $target, 2 )[0]; $admin_path = wp_parse_url( admin_url( 'admin.php' ), PHP_URL_PATH ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only the exact authorized recovery page is matched; it grants no mutation and later forms require nonces. $raw_page = isset( $_GET['page'] ) && is_string( $_GET['page'] ) ? wp_unslash( $_GET['page'] ) : ''; $page = sanitize_key( $raw_page ); return $path === $admin_path && 'logged-ai-response' === $page && hash_equals( $page, $raw_page ) && is_user_logged_in() && current_user_can( 'logged_ai_manage_response' ); } /** * Bridge an optional response observer to the shared bounded request sink. * The add-on supplies its implementation; Free has no automatic admission logic. * @param array $policy Opaque add-on policy validated by its response implementation. * @param \wpdb $committed Original add-on committed connection. * @return int Admitted request identifier or zero when the add-on is unavailable. */ public static function admit_automatic( array $policy, \wpdb $committed ): int { if ( ! class_exists( '\LoggedAI\Pro\AutomaticResponse', false ) ) { return 0; } return \LoggedAI\Pro\AutomaticResponse::admit( $policy, $committed, static fn( array $record ): int => self::store( $record ), static function ( int $id, int $network, int $site, string $generation, bool $fresh ): void { self::$pending = array( $id, $network, $site, $generation, true ); if ( $fresh ) { register_shutdown_function( array( self::class, 'finish' ) ); } } ); } /** * Finalize admitted response facts only while their original context remains current. * * @return void */ public static function finish(): void { if ( null === self::$pending ) { return; } global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ [$id, $network, $site] = self::$pending; $automatic_receipt = ! empty( self::$pending[4] ); $bound = static fn(): bool => ! $automatic_receipt || ( class_exists( \LoggedAI\Pro\AutomaticResponse::class, false ) && \LoggedAI\Pro\AutomaticResponse::final_receipt( $id, $network, $site ) !== array() ); // An automatic marker can outlive a callback which changes native routing. // In that case retain the unconfirmed row; never finish through another namespace. if ( ! $bound() ) { self::$pending = null; return; } $code = http_response_code(); // Some SAPIs do not report a code. Do not coerce an unavailable result to 200. $code = is_int( $code ) && $code >= 100 && $code <= 599 ? $code : self::$reported_status; $final = array( 'status' => $code, 'response_at' => gmdate( 'Y-m-d H:i:s' ), ); if ( ! empty( self::$pending[4] ) && class_exists( \LoggedAI\Pro\AutomaticResponse::class, false ) ) { $final += \LoggedAI\Pro\AutomaticResponse::final_receipt( $id, $network, $site ); } if ( class_exists( \LoggedAI\Pro\AutomaticResponse::class, false ) ) { $final += \LoggedAI\Pro\AutomaticResponse::final_fields( $network, $site ); } if ( ! $bound() ) { self::$pending = null; return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist final response facts in their originally admitted request row. $updated = $wpdb->update( self::table( 'requests' ), $final, array( 'id' => $id, 'network_id' => $network, 'site_id' => $site, ) ); if ( ! $bound() ) { self::$pending = null; return; } if ( false === $updated ) { self::degraded( $network, $site, 'response_status_update_failed' ); } if ( ! $bound() ) { self::$pending = null; return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read the current stored group link before recording the admitted request outcome. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT group_key,own_response FROM %i WHERE network_id=%d AND site_id=%d AND id=%d', self::table( 'requests' ), $network, $site, $id ), ARRAY_A ); if ( ! $bound() ) { self::$pending = null; return; } if ( $row && $row['group_key'] && is_int( $code ) && $code >= 100 && $code <= 599 ) { $column = 'status_' . intdiv( $code, 100 ) . 'xx'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomically update stored group outcome counters from the finalized request fact. $group_updated = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET unknown_count=GREATEST(CAST(unknown_count AS SIGNED)-1,0),%i=%i+1,own_responses=own_responses+%d WHERE network_id=%d AND site_id=%d AND group_key=%s', self::table( 'request_groups' ), $column, $column, 'wordpress_response_issued' === $row['own_response'] ? 1 : 0, $network, $site, $row['group_key'] ) ); if ( ! $bound() ) { self::$pending = null; return; } if ( false === $group_updated ) { self::degraded( $network, $site, 'group_response_gap' ); } } if ( ! $bound() ) { self::$pending = null; return; } $automatic = self::$pending[3] ?? null; self::$pending = null; if ( is_string( $automatic ) && class_exists( \LoggedAI\Pro\AutomaticResponse::class, false ) ) { \LoggedAI\Pro\AutomaticResponse::finalized(); } } /** * Record a finite request-health gap code for the selected response state. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param string $code Finite request-health gap code selected by the caller. * * @return void */ public static function degraded( int $network, int $site, string $code ): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist the changed finite request-health code in actual scoped response storage. $wpdb->query( $wpdb->prepare( 'UPDATE %i SET degraded=%s WHERE network_id=%d AND site_id=%d AND degraded<>%s', self::table( 'response_state' ), $code, $network, $site, $code ) ); } /** * Derive the installation request-mutex name from its database and prefix. * * @return string Stable hashed mutex name for this installation. */ private static function lock_name(): string { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return 'logged_ai_req_' . substr( hash( 'sha256', DB_NAME . ':' . $wpdb->base_prefix ), 0, 36 ); } /** * Run work under the request mutex and release the same connection lease. * * @param callable $callback Work to run after acquiring the request mutex. * * @return mixed Callback result, or an error when the mutex is unavailable. */ public static function locked( callable $callback ): mixed { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $connection = $wpdb; $name = self::lock_name(); if ( '1' !== (string) $connection->get_var( $connection->prepare( 'SELECT GET_LOCK(%s,0)', $name ) ) ) { return new \WP_Error( 'busy', 'Request storage is busy; no change was made. Retry shortly.' ); } try { return $callback(); } finally { $connection->get_var( $connection->prepare( 'SELECT RELEASE_LOCK(%s)', $name ) ); } } /** * Recheck destructive-work authority while holding the required privacy locks. * * Destructive request work takes Derived before the request mutex; capture keeps its original lock. * * @param callable $work Operation to execute within the method context boundary. * @param callable $authorize Callback that rechecks current destructive-work authority. * * @return mixed Callback result, or an error when context, authority or locking changes. */ private static function privacy_locked( callable $work, callable $authorize ): mixed { global $wpdb; $caller = $wpdb; $origin = Derived::native_context( $caller ); $current = static fn(): bool => ( $GLOBALS['wpdb'] ?? null ) === $caller && Derived::native_context( $caller ) === $origin; $allowed = static fn(): bool => $current() && $authorize() && $current(); if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Current request privacy authority is unavailable.' ); } $held = static fn(): bool => $current() && Derived::mutation_allowed() && $allowed(); $locked = static function () use ( $work, $held ): mixed { $result = self::locked( static function () use ( $work, $held ): mixed { if ( ! $held() ) { return new \WP_Error( 'forbidden', 'Request privacy context changed while acquiring its lease.' ); } $result = $work(); return $held() ? $result : new \WP_Error( 'forbidden', 'Request privacy context changed before completion.' ); } ); return $held() ? $result : new \WP_Error( 'forbidden', 'Request privacy context changed while releasing its lease.' ); }; $result = Derived::mutation_allowed() ? $locked() : Derived::with_mutation( $locked, $allowed ); return $allowed() ? $result : new \WP_Error( 'forbidden', 'Request privacy authority changed before return.' ); } /** * Reduce case references before discarding their selected request identities. * * Exact current SQL-selected request pairs, grouped by their server-owned member site. * * @param int $network Selected network identifier. * @param array $rows Rows to select, minimize or reduce for this operation. * @param Scope|null $scope Operation scope whose current permissions are checked. Null selects the internal server adapter. * @param int|null $known_site Optional server-selected site for rows whose site is already known. * * @return true|\WP_Error True after dependent cleanup, or an error retaining the original references. */ private static function erase_case_references( int $network, array $rows, ?Scope $scope = null, ?int $known_site = null ): bool|\WP_Error { if ( count( $rows ) > 500 ) { return new \WP_Error( 'privacy', 'Request-case cleanup exceeded its source batch.' ); } $sites = null !== $known_site && $known_site > 0 ? array( $known_site => array() ) : array(); foreach ( $rows as $row ) { $site = (int) ( $row['site_id'] ?? 0 ); if ( $site < 1 ) { return new \WP_Error( 'privacy', 'A current request-case source site is unavailable.' ); } $sites[ $site ][] = array( 'id' => $row['id'], 'uid' => $row['uid'], ); } foreach ( $sites as $site => $pairs ) { if ( null !== $scope && 0 === $scope->site_id ) { $result = ScopedRequestCasePrivacy::erase_requests_for_network( $scope, $site, $pairs ); } elseif ( get_current_blog_id() !== $site ) { $result = ScopedRequestCasePrivacy::erase_requests_for_network_retention( $network, $site, $pairs ); } else { $result = ScopedRequestCasePrivacy::erase_requests( $network, $site, $pairs ); } if ( is_wp_error( $result ) || ! $result['done'] ) { return is_wp_error( $result ) ? $result : new \WP_Error( 'privacy', 'Request-case privacy cleanup remains pending; original request references were retained.' ); } } return true; } /** * Reserve bounded admission capacity before inserting minimized request evidence. * * Atomic installation + site reservation, no caller transaction ownership. * * @param array $record Minimized admitted request fields. * * @return int Inserted request identifier, or zero when admission cannot complete. */ private static function store( array $record ): int { if ( isset( $record['evidence'] ) && strlen( $record['evidence'] ) > 4096 ) { self::degraded( (int) $record['network_id'], (int) $record['site_id'], 'request_evidence_bound' ); return 0; } $result = self::locked( static function () use ( $record ): int { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $network = (int) $record['network_id']; $site = (int) $record['site_id']; $table = self::table( 'request_limits' ); $now = time(); $minute = intdiv( $now, 60 ); $day = intdiv( $now, 86400 ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Initialize actual installation and site admission counters while holding the request mutex. $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (network_id,site_id) VALUES (0,0),(%d,%d)", $table, $network, $site ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Read both current admission counters under the mutex; cached quotas could permit excess storage. $limits = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE (network_id=0 AND site_id=0) OR (network_id=%d AND site_id=%d)", $table, $network, $site ), ARRAY_A ); if ( 2 !== count( $limits ) ) { return 0; } foreach ( $limits as $limit ) { $global = 0 === (int) $limit['site_id']; if ( ( (int) $limit['minute_bucket'] === $minute && (int) $limit['minute_used'] >= ( $global ? 600 : 120 ) ) || ( (int) $limit['day_bucket'] === $day && (int) $limit['day_used'] >= ( $global ? 25000 : 10000 ) ) || (int) $limit['retained_reserved'] >= ( $global ? 250000 : 100000 ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Persist the saturated dropped-observation count when actual admission capacity is exhausted. $wpdb->query( $wpdb->prepare( "UPDATE %i SET dropped=LEAST(dropped+1,9223372036854775807),last_drop=%d WHERE network_id=%d AND site_id=%d", $table, $now, $network, $site ) ); return 0; } } // Reserve before insert. A killed process can conservatively consume budget; never exceed it. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Atomically reserve installation and site quotas before admitting stored request evidence. if ( 2 !== $wpdb->query( $wpdb->prepare( "UPDATE %i SET minute_used=IF(minute_bucket=%d,minute_used+1,1),minute_bucket=%d,day_used=IF(day_bucket=%d,day_used+1,1),day_bucket=%d,retained_reserved=retained_reserved+1 WHERE ((network_id=0 AND site_id=0) OR (network_id=%d AND site_id=%d)) AND (minute_bucket<>%d OR minute_used%d OR day_usedinsert( self::table( 'requests' ), $record ) ) { self::degraded( $network, $site, 'request_insert_failed' ); return 0; } $id = (int) $wpdb->insert_id; self::group_admitted( $id, $record ); return $id; } ); if ( is_wp_error( $result ) ) { self::degraded( (int) $record['network_id'], (int) $record['site_id'], 'admission_busy_partial' ); return 0; } return (int) $result; } /** * Update bounded request groups from an already admitted observation. * * Called inside the admission lock, using only already-admitted minimized facts. * * @param int $id Identifier of the request already admitted to storage. * @param array $record Minimized admitted request fields. * * @return void */ private static function group_admitted( int $id, array $record ): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $network = (int) $record['network_id']; $site = (int) $record['site_id']; $window = intdiv( time(), 60 ) * 60; $key = hash( 'sha256', implode( ':', array( $network, $site, $window, $record['source'], $record['family'] ) ) ); $table = self::table( 'request_groups' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Read the actual admitted group and sample IDs under the admission mutex. $group = $wpdb->get_row( $wpdb->prepare( "SELECT group_key,sample_ids FROM %i WHERE network_id=%d AND site_id=%d AND group_key=%s", $table, $network, $site, $key ), ARRAY_A ); if ( '' !== $wpdb->last_error ) { self::degraded( $network, $site, 'group_query_failed' ); return; } if ( ! $group ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomically reserve actual installation and site group capacity before creating a group. $reserved = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET groups_reserved=groups_reserved+1 WHERE ((network_id=0 AND site_id=0) OR (network_id=%d AND site_id=%d)) AND groups_reservedinsert( $table, array( 'group_key' => $key, 'network_id' => $network, 'site_id' => $site, 'window_start' => $window, 'source' => $record['source'], 'family' => $record['family'], 'first_at' => $record['captured_at'], 'last_at' => $record['captured_at'], ) ) ) { self::degraded( $network, $site, 'group_insert_failed' ); return; } $group = array( 'sample_ids' => '[]' ); } $samples = json_decode( $group['sample_ids'], true ); $samples = is_array( $samples ) ? array_values( array_filter( $samples, static fn( $sample ): bool => is_string( $sample ) && ctype_digit( $sample ) ) ) : array(); $samples = array_slice( $samples, 0, 5 ); if ( count( $samples ) < 5 ) { $samples[] = (string) $id; } // Capture can finish out of order; retain extrema of the admitted timestamps. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist admitted group totals, timestamp extrema and bounded sample IDs atomically. if ( 1 !== $wpdb->query( $wpdb->prepare( 'UPDATE %i SET admitted_count=admitted_count+1,partial_count=partial_count+%d,unknown_count=unknown_count+1,first_at=LEAST(first_at,%s),last_at=GREATEST(last_at,%s),sample_ids=%s WHERE network_id=%d AND site_id=%d AND group_key=%s', $table, (int) $record['partial'], $record['captured_at'], $record['captured_at'], wp_json_encode( $samples ), $network, $site, $key ) ) ) { self::degraded( $network, $site, 'group_update_failed' ); return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist the admitted observation to group association in the exact scoped source row. if ( false === $wpdb->update( self::table( 'requests' ), array( 'group_key' => $key ), array( 'network_id' => $network, 'site_id' => $site, 'id' => $id, ) ) ) { self::degraded( $network, $site, 'group_membership_gap' ); } } /** * Load a bounded retained group page after checking scoped event-read authority. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int $limit Requested batch or page limit, clamped to this operation's finite bounds. * * @return array|\WP_Error Recent retained groups, or an authorization or query error. */ public static function groups( Scope $scope, int $limit = 50 ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_view_events' ) ) { return new \WP_Error( 'forbidden', 'Request-group permission is unavailable.' ); } global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $cutoff = time() - max( 1, (int) self::privacy_settings()['request_group_days'] ) * DAY_IN_SECONDS; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current retained groups for the authorized site; cached rows could outlive cleanup. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND window_start>=%d ORDER BY window_start DESC,group_key LIMIT %d', self::table( 'request_groups' ), $scope->network_id, $scope->site_id, $cutoff, min( 100, max( 1, $limit ) ) ), ARRAY_A ); if ( ! is_array( $rows ) || '' !== $wpdb->last_error ) { self::degraded( $scope->network_id, $scope->site_id, 'group_query_failed' ); return new \WP_Error( 'groups', 'Request groups could not be loaded.' ); } return $rows; } /** * Read the response-state row for the selected network and site. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return array|null Stored state row, or null when unavailable. */ public static function state( int $network, int $site ): ?array { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current scoped response state so revocation and emergency disable are not cached. $state = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d', self::table( 'response_state' ), $network, $site ), ARRAY_A ); return is_array( $state ) ? $state : null; } /** * Read the explicit operator constant that disables local response. * * @return bool Whether the recovery constant is defined as true. */ public static function recovery_disabled(): bool { return defined( 'LOGGED_AI_DISABLE_RESPONSE' ) && true === LOGGED_AI_DISABLE_RESPONSE; } /** * Hash policy fields using the compiler associated with their parser version. * * @param array $policy Stored or compiled policy fields being inspected. * * @return string Authentication hash of the selected policy fields. */ public static function compiled_hash( array $policy ): string { if ( class_exists( RequestsAutomaticPolicy::class ) && ( $policy['parser_version'] ?? '' ) === RequestsAutomaticPolicy::VERSION ) { return RequestsAutomaticPolicy::hash( $policy ); } if ( ( $policy['parser_version'] ?? '' ) === RequestsPolicy::VERSION ) { return RequestsPolicy::hash( $policy ); } $fields = array(); foreach ( array( 'network_id', 'site_id', 'creator', 'parser_version', 'safe_path', 'methods', 'ttl', 'evidence_id', 'revision' ) as $key ) { $fields[ $key ] = (string) ( $policy[ $key ] ?? '' ); } return hash_hmac( 'sha256', wp_json_encode( $fields ), wp_salt( 'auth' ) ); } /** * Validate stored policy structure and its compiler-bound authentication hash. * * @param array $policy Stored or compiled policy fields being inspected. * * @return bool Whether the policy satisfies its supported compiler format. */ public static function valid_policy( array $policy ): bool { if ( class_exists( RequestsAutomaticPolicy::class ) && ( $policy['parser_version'] ?? '' ) === RequestsAutomaticPolicy::VERSION ) { return RequestsAutomaticPolicy::valid( $policy ); } if ( ( $policy['parser_version'] ?? '' ) === RequestsPolicy::VERSION ) { return RequestsPolicy::valid( $policy ); } return self::PARSER === $policy['parser_version'] && 'manual' === $policy['origin'] && in_array( $policy['safe_path'], self::PATHS, true ) && 'GET,HEAD' === $policy['methods'] && in_array( (int) $policy['ttl'], self::TTLS, true ) && 1 === (int) $policy['revision'] && hash_equals( self::compiled_hash( $policy ), (string) $policy['compiled_hash'] ); } /** * Select a currently eligible manual policy from a fresh bounded storage read. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param string $path Selected request path or supported path condition. * @param string $target Request target inspected transiently without persisting raw contents. * @param string $method Native HTTP method used for the matching decision. * * @param bool $observation_only Whether to select a non-enforcing observation policy. * * @return array|null Matching unexpired policy, or null when its mode or validation is unavailable. */ private static function matching_policy( int $network, int $site, string $path, string $target = '', string $method = 'GET', bool $observation_only = false ): ?array { if ( self::recovery_disabled() ) { return null; } global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $state = self::state( $network, $site ); if ( null === $state ) { return null; } if ( self::VERSION !== (int) $state['schema_version'] || ! in_array( (int) $state['enabled'], array( 0, 1 ), true ) ) { self::degraded( $network, $site, 'invalid_response_state' ); return null; } if ( ! $observation_only && 1 !== (int) $state['enabled'] ) { return null; } $now = time(); $settings = self::privacy_settings(); $source_context = RequestsSource::current(); $settings['source_generation'] = $source_context['generation']; $peer = $source_context['address']; $view = RequestsPolicy::view( $target, $method, is_string( $peer ) ? $peer : null ); // Fresh database read per eligible request: no cross-worker policy cache. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reload active unexpired manual policies for every eligible request; cached policies could enforce revoked targets. $policies = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND origin=%s AND state=%s AND expires_at>%d ORDER BY id LIMIT 101', self::table( 'response_policies' ), $network, $site, 'manual', $observation_only ? 'observe' : 'active', $now ), ARRAY_A ); if ( ! is_array( $policies ) || '' !== $wpdb->last_error || count( $policies ) > 100 ) { self::degraded( $network, $site, 'policy_load_failed' ); return null; } foreach ( $policies as $policy ) { if ( RequestsPolicy::personal_unavailable( $policy, $settings ) ) { continue; } if ( ! self::valid_policy( $policy ) || (int) $policy['starts_at'] <= 0 || (int) $policy['expires_at'] - (int) $policy['starts_at'] !== (int) $policy['ttl'] ) { self::degraded( $network, $site, 'invalid_policy_fail_open' ); return null; } } foreach ( $policies as $policy ) { if ( RequestsPolicy::personal_unavailable( $policy, $settings ) ) { continue; } $matches = RequestsPolicy::VERSION === $policy['parser_version'] ? RequestsPolicy::matches( $policy, $view, $settings ) : '' !== $path && $policy['safe_path'] === $path; if ( ! $observation_only && ! $matches && RequestsPolicy::VERSION === $policy['parser_version'] && RequestsPolicy::matches( $policy, $view, $settings, true ) && count( self::$suppressed_policies ) < 8 ) { self::$suppressed_policies[] = array( 'policy_id' => $policy['id'], 'revision' => (int) $policy['revision'], 'effect' => 'this_policy_response_suppressed', ); } if ( $matches && (int) $policy['starts_at'] <= $now && (int) $policy['expires_at'] > $now ) { return $policy; } } return null; } /** * Check read access in the actual current network and site. * * @phpstan-impure Reads current native authority or mutable context. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return bool Whether a supported viewer capability or native CLI context permits reading. */ private static function read_allowed( int $network, int $site ): bool { if ( Scope::network() !== $network || get_current_blog_id() !== $site ) { return false; } if ( 0 === get_current_user_id() && defined( 'WP_CLI' ) && WP_CLI ) { return true; } return Scope::can( 'logged_ai_view_events' ) || Scope::can( 'logged_ai_export_events' ) || Scope::can( 'logged_ai_manage_response' ); } /** * Read retained request evidence and reapply current scoped privacy before return. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int $id Positive identifier of the retained request observation. * * @return array|\WP_Error Visible selected evidence rows, or an authorization or query error. */ public static function observation( Scope $scope, int $id ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_view_events' ) || $id < 1 ) { return new \WP_Error( 'forbidden', 'Request evidence access denied.' ); } global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $settings = self::privacy_settings(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read the currently retained exact observation before applying scoped privacy projection. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND id=%d AND captured_at>=%s LIMIT 1', self::table( 'requests' ), $scope->network_id, $scope->site_id, $id, gmdate( 'Y-m-d H:i:s', time() - max( 1, (int) $settings['request_days'] ) * DAY_IN_SECONDS ) ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $rows ) ) { return new \WP_Error( 'request_query', 'Request evidence could not be loaded.' ); } if ( ! $scope->valid_for( 'logged_ai_view_events' ) ) { return new \WP_Error( 'forbidden', 'Request evidence authority changed.' ); } return self::for_scope( $scope, $rows ); } /** * Read a bounded request page and recheck its principal, scope and privacy. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param int $limit Requested batch or page limit, clamped to this operation's finite bounds. * * @return array|\WP_Error Visible retained request rows, or an authorization or query error. */ public static function observations( int $network, int $site, int $limit = 50 ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $principal = get_current_user_id(); $read_scope = null; $read_capability = 'logged_ai_view_events'; if ( $principal > 0 ) { foreach ( array( 'logged_ai_view_events', 'logged_ai_export_events', 'logged_ai_manage_response' ) as $cap ) { $candidate = Scope::current( $cap ); if ( ! is_wp_error( $candidate ) ) { $read_scope = $candidate; $read_capability = $cap; break; } } } if ( ! self::read_allowed( $network, $site ) ) { return new \WP_Error( 'forbidden', 'Current-site request evidence authority is required.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current bounded request evidence after checking actual site and viewer authority. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND captured_at>=%s ORDER BY id DESC LIMIT %d', self::table( 'requests' ), $network, $site, gmdate( 'Y-m-d H:i:s', time() - max( 1, (int) self::privacy_settings()['request_days'] ) * DAY_IN_SECONDS ), min( 500, max( 1, $limit ) ) ), ARRAY_A ); if ( ! is_array( $rows ) || '' !== $wpdb->last_error ) { self::degraded( $network, $site, 'request_query_failed' ); return new \WP_Error( 'request_query', 'Request evidence could not be loaded. This is not an empty result.' ); } if ( get_current_user_id() !== $principal || ! self::read_allowed( $network, $site ) ) { return new \WP_Error( 'forbidden', 'Request evidence authority changed.' ); } if ( null !== $read_scope ) { return self::for_scope( $read_scope, $rows, $read_capability ); } $rows = self::for_viewer( $rows, $network, $site ); return get_current_user_id() === $principal ? $rows : new \WP_Error( 'forbidden', 'Request evidence principal changed.' ); } /** * Read bounded policies and project current validity, expiry and target visibility. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return array|\WP_Error Projected policies, or an authorization or query error. */ public static function policies( int $network, int $site ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $principal = get_current_user_id(); $read_scope = null; $read_capability = 'logged_ai_view_events'; if ( $principal > 0 ) { foreach ( array( 'logged_ai_manage_response', 'logged_ai_view_events', 'logged_ai_export_events' ) as $cap ) { $candidate = Scope::current( $cap ); if ( ! is_wp_error( $candidate ) ) { $read_scope = $candidate; $read_capability = $cap; break; } } } if ( ! self::read_allowed( $network, $site ) ) { return new \WP_Error( 'forbidden', 'Current-site request evidence authority is required.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current scoped policy rows before projecting expiry, validity and personal-target visibility. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d ORDER BY created_at DESC,id LIMIT 100', self::table( 'response_policies' ), $network, $site ), ARRAY_A ); if ( ! is_array( $rows ) || '' !== $wpdb->last_error ) { self::degraded( $network, $site, 'policy_query_failed' ); return new \WP_Error( 'policy_query', 'Policies could not be loaded. This is not an empty result.' ); } $settings = self::privacy_settings(); foreach ( $rows as &$row ) { if ( RequestsPolicy::personal_unavailable( $row, $settings ) || ! empty( $row['target_erased'] ) ) { $row['target_json'] = null; $row['compiled_hash'] = ''; $row['source_request_uid'] = null; $row['target_erased'] = 1; $row['state'] = 'disabled'; continue; } if ( (int) $row['expires_at'] <= time() && in_array( $row['state'], array( 'draft', 'active', 'observe' ), true ) ) { $row['state'] = 'expired'; } if ( ! self::valid_policy( $row ) ) { $row['state'] = 'failed'; $row['target_json'] = null; $row['compiled_hash'] = ''; $row['source_request_uid'] = null; } } if ( class_exists( RequestsAutomaticPolicy::class ) ) { $rows = RequestsAutomaticPolicy::project_states( $rows ); } if ( get_current_user_id() !== $principal || ! self::read_allowed( $network, $site ) ) { return new \WP_Error( 'forbidden', 'Request evidence authority changed.' ); } if ( null !== $read_scope ) { return self::policies_for_scope( $read_scope, $rows, $read_capability ); } $rows = self::policies_for_viewer( $rows, $network, $site ); return get_current_user_id() === $principal ? $rows : new \WP_Error( 'forbidden', 'Policy evidence principal changed.' ); } /** * Read a bounded policy-history page in the namespace selected by the caller. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return array|\WP_Error Recent stored history rows, or a query error. */ public static function history( int $network, int $site ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current scoped response history so erased actors and recent control outcomes are reflected. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d ORDER BY id DESC LIMIT 100', self::table( 'response_history' ), $network, $site ), ARRAY_A ); if ( ! is_array( $rows ) || '' !== $wpdb->last_error ) { self::degraded( $network, $site, 'history_query_failed' ); return new \WP_Error( 'history_query', 'Policy history could not be loaded. This is not an empty result.' ); } return $rows; } /** * Append a scoped response-history fact with the current operator identifier. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param string $action Finite response-history action selected by the caller. * @param string|null $policy Optional policy identifier associated with this history fact. * @param int $revision Policy revision to record in the history fact. * * @return bool Whether the history insertion succeeded. */ public static function audit( int $network, int $site, string $action, ?string $policy = null, int $revision = 1 ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Append the actual scoped response-control audit fact before dependent actions proceed. return 1 === $wpdb->insert( self::table( 'response_history' ), array( 'network_id' => $network, 'site_id' => $site, 'policy_id' => $policy, 'actor_id' => get_current_user_id(), 'action' => $action, 'happened_at' => time(), 'revision' => $revision, ) ); } /** Bounded privacy cleanup. Admission/expiry remain safe if WP-Cron does not run. */ /** * Run scheduled bounded cleanup and record worker status in its original context. * * Native action adapter; the existing hourly event remains the retry schedule. * * @return void */ public static function retention_cron(): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; $origin = Derived::native_context( $db ); try { $result = self::retention(); } catch ( \Throwable $error ) { $result = new \WP_Error( 'worker', 'Request retention was interrupted.' ); } if ( Derived::native_context( $db ) === $origin && class_exists( Health::class, false ) ) { Health::record( 'request_retention', $result ); } } /** * Run or preview one bounded retention batch for the current site. * * @param bool $dry_run Whether to return a bounded preview without executing the mutation. * * @return array|\WP_Error Cleanup result or bounded preview counts, or a storage or authority error. */ public static function retention( bool $dry_run = false ): array|\WP_Error { if ( class_exists( Migrations::class ) && ! Migrations::ready( 'requests' ) ) { return new \WP_Error( 'migration_pending', 'Request schema upgrade is pending.' ); } [$network, $site] = self::scope_ids(); $result = self::retention_scope( $network, $site, 500, $dry_run ); if ( ! $dry_run || is_wp_error( $result ) ) { return $result; } return $result + array( 'preview_version' => 1, 'network_id' => (string) $network, 'site_id' => (string) $site, 'complete_effect_estimate' => false, 'dependent_cleanup' => 'Not enumerated: request-case links, copied alert data, automatic proof and peer-context reduction can defer or add to the eventual cleanup.', 'selection_semantics' => 'At most one current site batch per original table under the existing retention selectors. This is not an exact deletion forecast or a reservation for a later invocation.', ); } /** * Run or preview bounded retention after checking the supplied settings scope. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int $limit Requested batch or page limit, clamped to this operation's finite bounds. * @param bool $dry_run Whether to return a bounded preview without executing the mutation. * * @return array|\WP_Error Cleanup result or bounded preview counts, or an authorization or storage error. */ public static function purge_scope( Scope $scope, int $limit = 500, bool $dry_run = false ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ( ! $dry_run && ! $scope->valid_for( 'logged_ai_purge_data' ) ) ) { return new \WP_Error( 'forbidden', 'Request-retention permission is unavailable.' ); } return self::retention_scope( $scope->network_id, $scope->site_id, min( 500, max( 1, $limit ) ), $dry_run, $scope ); } /** * Recheck current settings and, for mutation, purge authority in the supplied scope. * * @phpstan-impure Reads current native authority or mutable context. * * @param Scope|null $scope Operation scope whose current permissions are checked. Null selects the internal server adapter. * @param bool $dry_run Whether to return a bounded preview without executing the mutation. * * @return bool Whether this scope or the internal server adapter authorizes the operation. */ private static function purge_authority( ?Scope $scope, bool $dry_run = false ): bool { if ( null === $scope ) { return true; } // Server-derived retention wrappers only. return 0 === $scope->site_id ? $scope->valid_for( 'logged_ai_manage_network_settings' ) && ( $dry_run || $scope->valid_for( 'logged_ai_purge_network_data' ) ) : $scope->valid_for( 'logged_ai_manage_settings' ) && ( $dry_run || $scope->valid_for( 'logged_ai_purge_data' ) ); } /** * Reduce dependent references before deleting bounded expired request data. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param int $limit Requested batch or page limit, clamped to this operation's finite bounds. * @param bool $dry_run Whether to return a bounded preview without executing the mutation. * @param Scope|null $scope Operation scope whose current permissions are checked. Null selects the internal server adapter. * * @return array|\WP_Error Batch counts and remaining-work hints, or an error preserving retry context. */ private static function retention_scope( int $network, int $site, int $limit, bool $dry_run, ?Scope $scope = null ): array|\WP_Error { return self::privacy_locked( static function () use ( $network, $site, $limit, $dry_run, $scope ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! self::purge_authority( $scope, $dry_run ) ) { return new \WP_Error( 'forbidden', 'Request purge authority changed.' ); } $settings = $dry_run ? Settings::retention_preview() : Settings::current(); if ( is_wp_error( $settings ) ) { return $settings; } $requests = self::table( 'requests' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Lock current expired source identities before reducing their dependent privacy copies. $case_rows = $wpdb->get_results( $wpdb->prepare( "SELECT id,request_uid AS uid,site_id FROM %i WHERE network_id=%d AND site_id=%d AND captured_at<%s ORDER BY id LIMIT %d FOR UPDATE", $requests, $network, $site, gmdate( 'Y-m-d H:i:s', time() - max( 1, (int) $settings['request_days'] ) * DAY_IN_SECONDS ), $limit ), ARRAY_A ); $ids = array_column( $case_rows, 'id' ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'cleanup', 'Request retention is unavailable.' ); } $group_table = self::table( 'request_groups' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Lock the actual expired group keys for this bounded retention batch. $group_keys = $wpdb->get_col( $wpdb->prepare( "SELECT group_key FROM %i WHERE network_id=%d AND site_id=%d AND window_start<%d ORDER BY window_start LIMIT %d FOR UPDATE", $group_table, $network, $site, time() - max( 1, (int) $settings['request_group_days'] ) * DAY_IN_SECONDS, $limit ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'cleanup', 'Group retention is unavailable.' ); } $history_cutoff = time() - max( 1, (int) $settings['policy_history_days'] ) * DAY_IN_SECONDS; $history_table = self::table( 'response_history' ); $policy_table = self::table( 'response_policies' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Lock old history candidates while retaining history for currently active unexpired policies. $history_ids = $wpdb->get_col( $wpdb->prepare( "SELECT h.id FROM %i h LEFT JOIN %i p ON p.network_id=h.network_id AND p.site_id=h.site_id AND p.id=h.policy_id AND p.state IN ('active','observe') AND p.expires_at>%d WHERE h.network_id=%d AND h.site_id=%d AND h.happened_at<%d AND p.id IS NULL ORDER BY h.id LIMIT %d FOR UPDATE", $history_table, $policy_table, time(), $network, $site, $history_cutoff, $limit ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'cleanup', 'Inactive policy history could not be selected.' ); } if ( ! self::purge_authority( $scope, $dry_run ) ) { return new \WP_Error( 'forbidden', 'Request purge authority changed.' ); } if ( $dry_run ) { // Exact bounded candidate membership; no deletion or privacy transformation. $counts = array( 'request_rows' => count( $ids ), 'group_rows' => count( $group_keys ), 'history_rows' => count( $history_ids ), ); foreach ( array( 'policy_rows' => array( 'response_policies', 'expires_at' ) ) as $key => [$kind, $time_column] ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read exact current bounded policy candidates for a retention preview without mutating them. $candidates = $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND %i<%d LIMIT %d', self::table( $kind ), $network, $site, $time_column, $history_cutoff, $limit ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'cleanup', 'Retention preview could not be loaded.' ); } $counts[ $key ] = count( $candidates ); } return $counts + array( 'dry_run' => true, 'peer_cleanup' => 'separate privacy-mode/expiry reduction; no deletion in dry run', 'batch_max' => $limit, 'more_may_remain' => in_array( $limit, $counts, true ), ); } if ( ! self::purge_authority( $scope, $dry_run ) ) { return new \WP_Error( 'forbidden', 'Request purge authority changed.' ); } $expired_cases = ScopedRequestCasePrivacy::expire( $network, $site ); if ( is_wp_error( $expired_cases ) || ! $expired_cases['done'] ) { return is_wp_error( $expired_cases ) ? $expired_cases : new \WP_Error( 'privacy', 'Expired request-case cleanup remains pending; original request rows were retained.' ); } $deleted = 0; if ( $ids ) { $cases = self::erase_case_references( $network, $case_rows, $scope, $site ); if ( is_wp_error( $cases ) ) { return $cases; } if ( class_exists( RequestsAutomaticPrivacy::class ) ) { $automatic = RequestsAutomaticPrivacy::erase_references( $network, $site, array_map( 'intval', $ids ), array(), $scope ); if ( is_wp_error( $automatic ) || ! $automatic['done'] ) { return is_wp_error( $automatic ) ? $automatic : new \WP_Error( 'privacy', 'Automatic proof cleanup is pending; original rows retained.' ); } } $alerts = Alerts::erase_request_facts( $network, $site, array_map( 'intval', $ids ), $scope ); if ( is_wp_error( $alerts ) || ! $alerts['done'] ) { return is_wp_error( $alerts ) ? $alerts : new \WP_Error( 'privacy', 'Request alert cleanup is pending; original rows retained for retry.' ); } $safe_ids = implode( ',', array_fill( 0, count( array_map( 'absint', $ids ) ), '%d' ) ); if ( ! self::purge_authority( $scope, $dry_run ) ) { return new \WP_Error( 'forbidden', 'Request purge authority changed.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- One generated %d token per normalized ID, followed by exactly that unpacked ID list; table and scope have their own placeholders. WPCS cannot count the unpacked list. Delete current selected rows only after dependent cleanup succeeds. $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=%d AND id IN ({$safe_ids})", $requests, $network, $site , ...array_map( 'absint', $ids ) ) ); if ( false === $deleted ) { return new \WP_Error( 'cleanup', 'Request retention failed.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release actual request reservations by the number of source rows deleted. if ( false === $wpdb->query( $wpdb->prepare( 'UPDATE %i SET retained_reserved=GREATEST(CAST(retained_reserved AS SIGNED)-%d,0) WHERE (network_id=0 AND site_id=0) OR (network_id=%d AND site_id=%d)', self::table( 'request_limits' ), $deleted, $network, $site ) ) ) { return new \WP_Error( 'cleanup', 'Request retention counters could not be reconciled.' ); } } $groups_deleted = 0; if ( $group_keys ) { $placeholders = implode( ',', array_fill( 0, count( $group_keys ), '%s' ) ); if ( ! self::purge_authority( $scope, $dry_run ) ) { return new \WP_Error( 'forbidden', 'Request purge authority changed.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table is fixed and the IN grammar is generated only by array_fill placeholders; every selected ID or group key is passed separately to prepare. Delete the exact selected expired groups under current purge authority. Two scope %d placeholders and N generated group-key %s slots receive network, site and exactly N selected group keys in that order. $groups_deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=%d AND group_key IN ({$placeholders})", $group_table, $network, $site, ...$group_keys ) ); if ( false === $groups_deleted ) { return new \WP_Error( 'cleanup', 'Group retention failed.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release actual group reservations by the number of groups deleted. if ( false === $wpdb->query( $wpdb->prepare( 'UPDATE %i SET groups_reserved=GREATEST(CAST(groups_reserved AS SIGNED)-%d,0) WHERE (network_id=0 AND site_id=0) OR (network_id=%d AND site_id=%d)', self::table( 'request_limits' ), $groups_deleted, $network, $site ) ) ) { return new \WP_Error( 'cleanup', 'Group retention counters could not be reconciled.' ); } } $history_cutoff = time() - max( 1, (int) $settings['policy_history_days'] ) * DAY_IN_SECONDS; if ( ! self::purge_authority( $scope, $dry_run ) ) { return new \WP_Error( 'forbidden', 'Request purge authority changed.' ); } if ( class_exists( RequestsAutomaticPrivacy::class ) ) { $automatic = RequestsAutomaticPrivacy::prune( $network, $site, $scope ); if ( is_wp_error( $automatic ) || ! $automatic['done'] ) { return is_wp_error( $automatic ) ? $automatic : new \WP_Error( 'privacy', 'Automatic proof retention is pending; policy rows retained.' ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Delete actual expired policy rows only after automatic proof retention completes. $policy_deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d AND expires_at<%d LIMIT %d', self::table( 'response_policies' ), $network, $site, $history_cutoff, $limit ) ); $history_deleted = 0; if ( $history_ids ) { $safe_history_ids = implode( ',', array_fill( 0, count( array_map( 'absint', $history_ids ) ), '%d' ) ); if ( ! self::purge_authority( $scope, $dry_run ) ) { return new \WP_Error( 'forbidden', 'Request purge authority changed.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- One generated %d token per normalized ID, followed by exactly that unpacked ID list; table and scope have their own placeholders. WPCS cannot count the unpacked list. Delete current history IDs selected under the retention lock. $history_deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=%d AND id IN ({$safe_history_ids})", $history_table, $network, $site , ...array_map( 'absint', $history_ids ) ) ); } if ( false === $policy_deleted || false === $history_deleted ) { return new \WP_Error( 'cleanup', 'Response history retention failed.' ); } if ( ! self::purge_authority( $scope, $dry_run ) ) { return new \WP_Error( 'forbidden', 'Request purge authority changed.' ); } $peers = self::erase_peer_scope( $network, $site, $limit, $scope ); if ( is_wp_error( $peers ) ) { return $peers; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist cleanup completion only after the bounded request and peer cleanup succeeds. if ( false === $wpdb->update( self::table( 'response_state' ), array( 'last_cleanup' => time() ), array( 'network_id' => $network, 'site_id' => $site, ) ) ) { return new \WP_Error( 'cleanup', 'Retention completion could not be saved.' ); } return array( 'request_rows' => $deleted, 'group_rows' => $groups_deleted, 'policy_rows' => $policy_deleted, 'history_rows' => $history_deleted, 'peer_rows' => $peers['updated'], 'batch_max' => $limit, 'more_may_remain' => $deleted === $limit || $groups_deleted === $limit || $policy_deleted === $limit || $history_deleted === $limit || ! $peers['done'], ); }, static fn(): bool => self::purge_authority( $scope, $dry_run ) ); } /** * Reduce a bounded batch of address context under current settings authority. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int $limit Requested batch or page limit, clamped to this operation's finite bounds. * * @return array|\WP_Error Reduction counts and backlog status, or an authorization or storage error. */ public static function erase_peer_context( Scope $scope, int $limit = 500 ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'forbidden', 'Request-privacy permission is unavailable.' ); } return self::privacy_locked( static fn(): array|\WP_Error => self::erase_peer_scope( $scope->network_id, $scope->site_id, min( 500, max( 1, $limit ) ), $scope ), static fn(): bool => $scope->valid_for( 'logged_ai_manage_settings' ) ); } /** * Build fixed cleanup predicates from current privacy and source settings. * * @return array Request-address predicate followed by personal-policy predicate. */ private static function peer_cleanup_conditions(): array { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $settings = self::privacy_settings(); $mode = $settings['ip_mode']; $source = RequestsSource::get(); $expired = '(ip_expires_at IS NULL OR ip_expires_at<=%d OR captured_at<%s)'; $condition_values = 'full' === $mode ? array( time(), gmdate( 'Y-m-d H:i:s', time() - max( 1, (int) $settings['exact_ip_days'] ) * DAY_IN_SECONDS ) ) : array(); $policy_values = array(); $condition = 'disabled' === $mode ? "(client_mask IS NOT NULL OR ip_mode<>'disabled')" : ( 'masked' === $mode ? "ip_mode='full'" : "(ip_mode='full' AND {$expired})" ); $policy_condition = "target_kind='ip' AND target_erased=0"; if ( 'full' === $mode ) { $ineligible = 'target_expires_at<=%d OR target_captured_at<=%d OR target_settings_revision<>%d'; $policy_values = array( time(), time() - max( 1, (int) $settings['exact_ip_days'] ) * DAY_IN_SECONDS, (int) ( $settings['revision'] ?? 0 ) ); if ( ! is_wp_error( $source ) ) { $ineligible .= ' OR target_source_generation<>%s'; $policy_values[] = $source['generation']; } $policy_condition .= ' AND (' . $ineligible . ')'; } return array( $condition, $policy_condition, $condition_values, $policy_values ); } /** * Report bounded physical address-cleanup backlog for an authorized scope. * * Read-only physical backlog; read/export minimization is already immediate. * * @param Scope $scope Operation scope whose current permissions are checked. * * @return array|\WP_Error Backlog counts and their limits, or an authorization or query error. */ public static function peer_cleanup_status( Scope $scope ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'forbidden', 'Request-privacy permission is unavailable.' ); } return self::peer_cleanup_counts( $scope->network_id, $scope->site_id ); } /** * Reduce request context linked to scoped activity before its identities are erased. * * Activity retention must finish this reduction before deleting its identity join. * * @param Scope $scope Operation scope whose current permissions are checked. * @param array $request_uids Request correlation identifiers selected from the activity records. * * @return array|\WP_Error Linked-context cleanup progress, or an authorization or storage error. */ public static function erase_event_context( Scope $scope, array $request_uids ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! $scope->valid_for( 'logged_ai_purge_data' ) ) { return new \WP_Error( 'forbidden', 'Linked request-privacy permission is unavailable.' ); } return self::erase_event_context_scope( $scope->network_id, $scope->site_id, $request_uids, $scope ); } /** * Reduce event-linked context through the current-site server retention adapter. * * Cron derives its site internally and never impersonates an administrator. * * @param array $request_uids Request correlation identifiers selected from the activity records. * * @return array|\WP_Error Linked-context cleanup progress, or an adapter or storage error. */ public static function erase_event_context_for_retention( array $request_uids ): array|\WP_Error { if ( ! wp_doing_cron() && ! in_array( current_action(), array( 'logged_ai_retention', 'logged_ai_privacy_batch' ), true ) ) { return new \WP_Error( 'forbidden', 'The server retention context is required.' ); } [$network, $site] = self::scope_ids(); return self::erase_event_context_scope( $network, $site, $request_uids ); } /** * Reduce event-linked request context under current network purge authority. * * Network-origin events can share request IDs with observations on their originating site. * * @param Scope $scope Operation scope whose current permissions are checked. * @param array $request_uids Request correlation identifiers selected from the activity records. * * @return array|\WP_Error Network-linked cleanup progress, or an authorization or storage error. */ public static function erase_network_event_context( Scope $scope, array $request_uids ): array|\WP_Error { if ( 0 !== $scope->site_id || ! $scope->valid_for( 'logged_ai_manage_network_settings' ) || ! $scope->valid_for( 'logged_ai_purge_network_data' ) || ( class_exists( Migrations::class ) && ! Migrations::ready( 'requests' ) ) ) { return new \WP_Error( 'forbidden', 'Network request-privacy permission is unavailable.' ); } return self::erase_event_context_scope( $scope->network_id, null, $request_uids, $scope ); } /** * Reduce network-event context through the main-site server retention adapter. * * Main-site server cron receives only IDs selected from current-network events. * * @param array $request_uids Request correlation identifiers selected from the activity records. * * @return array|\WP_Error Network-linked cleanup progress, or an adapter or storage error. */ public static function erase_network_event_context_for_retention( array $request_uids ): array|\WP_Error { $network = Scope::network(); if ( ! is_multisite() || get_current_blog_id() !== (int) get_main_site_id( $network ) || ! in_array( current_action(), array( 'logged_ai_network_retention', 'logged_ai_network_privacy_batch' ), true ) || ( class_exists( Migrations::class ) && ! Migrations::ready( 'requests' ) ) ) { return new \WP_Error( 'forbidden', 'The current-network retention context is required.' ); } return self::erase_event_context_scope( $network, null, $request_uids ); } /** * Redact bounded event-linked request context after dependent privacy cleanup. * * @param int $network Selected network identifier. * @param int|null $site Selected site identifier, or null for the internal network-wide adapter. * @param array $request_uids Request correlation identifiers selected from the activity records. * @param Scope|null $scope Operation scope whose current permissions are checked. Null selects the internal server adapter. * * @return array|\WP_Error Updated and remaining counts with completion status, or an error retaining retry context. */ private static function erase_event_context_scope( int $network, ?int $site, array $request_uids, ?Scope $scope = null ): array|\WP_Error { if ( count( $request_uids ) > 500 ) { return new \WP_Error( 'batch', 'At most 500 internal request references can be reduced per batch.' ); } foreach ( $request_uids as $uid ) { if ( ! is_string( $uid ) || ! preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D', $uid ) ) { return new \WP_Error( 'batch', 'The batch contains an invalid internal request reference.' ); } } $request_uids = array_values( array_unique( $request_uids ) ); if ( ! $request_uids ) { return self::privacy_locked( static function () use ( $network, $site, $scope ): array|\WP_Error { $cases = self::erase_case_references( $network, array(), $scope, $site ); return is_wp_error( $cases ) ? $cases : array( 'updated' => 0, 'remaining' => 0, 'done' => true, ); }, static fn(): bool => self::purge_authority( $scope ) ); } return self::privacy_locked( static function () use ( $network, $site, $request_uids, $scope ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! self::purge_authority( $scope ) ) { return new \WP_Error( 'forbidden', 'Request erasure authority changed.' ); } $slots = implode( ',', array_fill( 0, count( $request_uids ), '%s' ) ); $policies = self::table( 'response_policies' ); $requests = self::table( 'requests' ); $scope_sql = 'network_id=%d AND (%d=1 OR site_id=%d)'; $match_values = array_merge( array( $network, null === $site ? 1 : 0, $site ?? 0 ), $request_uids ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The IN grammar contains only array_fill-generated %s placeholders; each bounded validated request UUID is passed separately to prepare. The internal template has three scope slots and N generated UUID %s slots; all corresponding values remain separate until the complete query is prepared. $policy_match = $scope_sql . " AND target_kind='ip' AND source_request_uid IN ({$slots})"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The IN grammar contains only array_fill-generated %s placeholders; each bounded validated request UUID is passed separately to prepare. The internal template has three scope slots and N generated UUID %s slots; all corresponding values remain separate until the complete query is prepared. $request_match = $scope_sql . " AND request_uid IN ({$slots})"; if ( ! self::purge_authority( $scope ) ) { return new \WP_Error( 'forbidden', 'Request erasure authority changed.' ); } if ( class_exists( RequestsAutomaticPrivacy::class ) ) { $automatic = RequestsAutomaticPrivacy::erase_references( $network, $site, array(), $request_uids, $scope ); if ( is_wp_error( $automatic ) || ! $automatic['done'] ) { return is_wp_error( $automatic ) ? $automatic : new \WP_Error( 'privacy', 'Linked automatic proof cleanup is pending; original references retained.' ); } } $alerts = Alerts::erase_request_references( $network, $site, $request_uids, $scope ); if ( is_wp_error( $alerts ) || ! $alerts['done'] ) { return is_wp_error( $alerts ) ? $alerts : new \WP_Error( 'privacy', 'Linked request alert cleanup is pending; original references retained.' ); } if ( ! self::purge_authority( $scope ) ) { return new \WP_Error( 'forbidden', 'Request erasure authority changed.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The complete query binds the table and timestamp followed by three scope values and exactly N UUIDs for N generated %s slots. Its internal predicate contains only fixed grammar and placeholders. Erase authoritative personal policy targets before source links. $personal = $wpdb->query( $wpdb->prepare( "UPDATE %i SET state='disabled',target_json=NULL,target_erased=1,compiled_hash='',source_request_uid=NULL,changed_at=%d WHERE {$policy_match} LIMIT 500", $policies, time(), ...$match_values ) ); if ( false === $personal ) { return new \WP_Error( 'privacy', 'Linked personal policy targets could not be erased. Keep the activity batch for retry.' ); } if ( ! self::purge_authority( $scope ) ) { return new \WP_Error( 'forbidden', 'Request erasure authority changed.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The complete query binds the table, three scope values and exactly N UUIDs for N generated %s slots. Lock current request identities before dependent case erasure; cached identities are insufficient. $case_rows = $wpdb->get_results( $wpdb->prepare( "SELECT id,request_uid AS uid,site_id FROM %i WHERE {$request_match} ORDER BY id LIMIT 500 FOR UPDATE", $requests, ...$match_values ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! self::purge_authority( $scope ) ) { return new \WP_Error( 'privacy', 'Current linked request identities could not be locked.' ); } $cases = self::erase_case_references( $network, $case_rows, $scope, $site ); if ( is_wp_error( $cases ) ) { return $cases; } $selected_ids = array_map( 'absint', array_column( $case_rows, 'id' ) ); $selected = $selected_ids ? 'id IN (' . implode( ',', array_fill( 0, count( $selected_ids ), '%d' ) ) . ')' : '0=1'; if ( ! self::purge_authority( $scope ) ) { return new \WP_Error( 'forbidden', 'Linked request erasure authority changed.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The complete query binds the table, random replacement salt, three scope values, N UUIDs and M selected integer IDs, in placeholder order. Generated IN lists contain only %s/%d tokens; an empty ID list uses fixed 0=1. Scrub actual selected rows after dependent cleanup. $changed = $wpdb->query( $wpdb->prepare( "UPDATE %i SET alert_state='skipped',client_mask=NULL,ip_mode='disabled',ip_expires_at=NULL,request_uid=LEFT(SHA2(CONCAT(request_uid,%s),256),32) WHERE {$request_match} AND {$selected} LIMIT 500", $requests, bin2hex( random_bytes( 16 ) ), ...array_merge( $match_values, $selected_ids ) ) ); if ( false === $changed ) { return new \WP_Error( 'privacy', 'Linked request context could not be erased. Keep the activity batch for retry.' ); } $remaining = 0; foreach ( array( $policies => $policy_match, $requests => $request_match, ) as $table => $condition ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The complete query binds the finite selected table, three scope values and exactly N request UUIDs for N generated %s slots. The two internal predicates contain only fixed grammar and tokens. Verify actual remaining sources under lock. $found = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE {$condition} LIMIT 501 FOR UPDATE", $table, ...$match_values ) ); /** * Validate remaining linked IDs before reporting request cleanup completion from the untyped wpdb adapter. * * @var mixed $found */ if ( ! is_array( $found ) || '' !== $wpdb->last_error ) { return new \WP_Error( 'privacy', 'Linked request cleanup completion could not be verified.' ); } $remaining += count( $found ); } return array( 'updated' => $personal + $changed, 'remaining' => $remaining, 'done' => 0 === $remaining, ); }, static fn(): bool => self::purge_authority( $scope ) ); } /** * Count bounded remaining request and policy privacy work in the selected site. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return array|\WP_Error Per-kind capped counts and completion status, or a query error. */ private static function peer_cleanup_counts( int $network, int $site ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ [$condition, $policy_condition, $condition_values, $policy_values] = self::peer_cleanup_conditions(); $counts = array(); foreach ( array( 'observations' => array( 'requests', $condition, $condition_values ), 'policy_targets' => array( 'response_policies', $policy_condition, $policy_values ), ) as $key => [$kind, $where, $values] ) { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The complete query binds the allowlisted table and scope followed by all ordered values returned with the fixed cleanup template. peer_cleanup_conditions supplies only literal branches and matching placeholders; native old/new comparisons cover all three privacy modes. Read current locked backlog without caching. $found = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND {$where} LIMIT 501 FOR UPDATE", self::table( $kind ), $network, $site, ...$values ) ); /** * Validate physical request IDs before reporting privacy cleanup status from the untyped wpdb adapter. * * @var mixed $found */ if ( '' !== $wpdb->last_error || ! is_array( $found ) ) { return new \WP_Error( 'privacy', 'Physical request-privacy cleanup status is unavailable.' ); } $counts[ $key ] = count( $found ); } if ( class_exists( RequestsAutomaticPrivacy::class ) ) { $pending = RequestsAutomaticPrivacy::pending( $network, $site ); if ( is_wp_error( $pending ) ) { return $pending; } $counts['automatic_references'] = $pending; } return $counts + array( 'remaining' => array_sum( $counts ), 'done' => 0 === array_sum( $counts ), 'counts_limited' => in_array( 501, $counts, true ), 'count_limit_per_kind' => 501, ); } /** * Reduce selected request addresses and personal targets after dependent cleanup. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param int $limit Requested batch or page limit, clamped to this operation's finite bounds. * @param Scope|null $scope Operation scope whose current permissions are checked. Null selects the internal server adapter. * * @return array|\WP_Error Updated counts and remaining backlog, or a privacy or authority error. */ private static function erase_peer_scope( int $network, int $site, int $limit, ?Scope $scope = null ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $settings = Settings::current(); if ( is_wp_error( $settings ) ) { return $settings; } $table = self::table( 'requests' ); [$condition, $policy_condition, $condition_values, $policy_values] = self::peer_cleanup_conditions(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The complete query binds table/network/site, all ordered fixed cleanup-template values, then the row limit. Native old/new comparisons cover all three privacy modes. Lock the current address-reduction batch before dependent erasure. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id,request_uid AS uid,site_id,captured_at,client_mask,ip_mode,ip_expires_at FROM %i WHERE network_id=%d AND site_id=%d AND {$condition} ORDER BY id LIMIT %d FOR UPDATE", ...array_merge( array( $table, $network, $site ), $condition_values, array( $limit ) ) ), ARRAY_A ); if ( ! is_array( $rows ) || '' !== $wpdb->last_error ) { return new \WP_Error( 'privacy', 'Request privacy cleanup could not load its batch.' ); } if ( null !== $scope && ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'forbidden', 'Request-privacy authority changed.' ); } $cases = self::erase_case_references( $network, $rows, $scope, $site ); if ( is_wp_error( $cases ) ) { return $cases; } if ( $rows && class_exists( RequestsAutomaticPrivacy::class ) ) { $automatic = RequestsAutomaticPrivacy::erase_references( $network, $site, array_map( 'intval', array_column( $rows, 'id' ) ), array(), $scope, true ); if ( is_wp_error( $automatic ) || ! $automatic['done'] ) { return is_wp_error( $automatic ) ? $automatic : new \WP_Error( 'privacy', 'Automatic proof reduction is pending; source context retained.' ); } } $updated = 0; foreach ( self::minimize_rows( $rows ) as $row ) { if ( null !== $scope && ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'forbidden', 'Request-privacy authority changed.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist the current minimized address fields in each exact scoped request row. $result = $wpdb->update( $table, array( 'client_mask' => $row['client_mask'], 'ip_mode' => $row['ip_mode'], 'ip_expires_at' => $row['ip_expires_at'], ), array( 'id' => $row['id'], 'network_id' => $network, 'site_id' => $site, ) ); if ( false === $result ) { return new \WP_Error( 'privacy', 'Request privacy cleanup failed before completing the batch.' ); } $updated += $result; } $policies = self::table( 'response_policies' ); if ( null !== $scope && ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'forbidden', 'Request-privacy authority changed.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The complete query binds table/timestamp/network/site, all ordered fixed cleanup-template values, then the row limit. Native old/new comparisons cover all three privacy modes. Erase current personal targets after their dependent cleanup. $policy_updated = $wpdb->query( $wpdb->prepare( "UPDATE %i SET state='disabled',target_json=NULL,target_erased=1,compiled_hash='',source_request_uid=NULL,changed_at=%d WHERE network_id=%d AND site_id=%d AND {$policy_condition} LIMIT %d", ...array_merge( array( $policies, time(), $network, $site ), $policy_values, array( $limit ) ) ) ); if ( false === $policy_updated ) { return new \WP_Error( 'privacy', 'Personal policy targets could not be erased. Enforcement remains disabled by current privacy.' ); } if ( class_exists( RequestsAutomaticPrivacy::class ) ) { $automatic = RequestsAutomaticPrivacy::prune( $network, $site, $scope ); if ( is_wp_error( $automatic ) || ! $automatic['done'] ) { return is_wp_error( $automatic ) ? $automatic : new \WP_Error( 'privacy', 'Automatic reference reduction remains pending.' ); } } $counts = self::peer_cleanup_counts( $network, $site ); return is_wp_error( $counts ) ? $counts : $counts + array( 'updated' => $updated + $policy_updated, 'policy_targets_erased' => $policy_updated, ); } /** * Reduce event-linked request, policy-creator and history-actor context for a user. * * Root erases event identities only after this scoped linked-context pass reports done. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int $user Registered user identifier whose linked context is being erased. * @param int $limit Requested batch or page limit, clamped to this operation's finite bounds. * * @return array|\WP_Error Updated and remaining counts with completion status, or a privacy or authority error. */ public static function erase_user( Scope $scope, int $user, int $limit = 500 ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! $scope->valid_for( 'logged_ai_purge_data' ) || $user < 1 ) { return new \WP_Error( 'forbidden', 'User-erasure permission or subject is unavailable.' ); } return self::privacy_locked( static function () use ( $scope, $user, $limit ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! $scope->valid_for( 'logged_ai_purge_data' ) ) { return new \WP_Error( 'forbidden', 'User-erasure authority changed.' ); } $limit = min( 500, max( 1, $limit ) ); $network = $scope->network_id; $site = $scope->site_id; if ( class_exists( RequestsAutomaticPrivacy::class ) ) { $automatic = RequestsAutomaticPrivacy::erase_operator( $network, $site, $user, $scope ); if ( is_wp_error( $automatic ) || ! $automatic['done'] ) { return is_wp_error( $automatic ) ? $automatic : new \WP_Error( 'privacy', 'Automatic operator cleanup is pending; original identities retained.' ); } } $requests = self::table( 'requests' ); $events = Store::table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Fixed native event/request/policy tables and literal personal-target grammar surround the separately prepared scoped user predicate; remaining values use placeholders. Lock actual requests joined to the selected user before erasing their original activity identities. $case_rows = $wpdb->get_results( $wpdb->prepare( "SELECT r.id,r.request_uid AS uid,r.site_id FROM %i r INNER JOIN %i e ON e.request_id=r.request_uid AND e.network_id=%d AND e.site_id=%d AND (e.actor_id=%d OR e.subject_id=%d OR (e.object_type='user' AND e.object_id=%d)) WHERE r.network_id=%d AND r.site_id=%d ORDER BY r.id LIMIT %d FOR UPDATE", $requests, $events, $network, $site, $user, $user, $user, $network, $site, $limit ), ARRAY_A ); $ids = array_column( $case_rows, 'id' ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'privacy', 'Linked request context could not be loaded.' ); } $policies = self::table( 'response_policies' ); // Personal policies can outlive their source observation's shorter retention. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Fixed native event/request/policy tables and literal personal-target grammar surround the separately prepared scoped user predicate; remaining values use placeholders. Lock actual personal policies linked to the user even when their request observation has expired. $personal_ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.id FROM %i p INNER JOIN %i e ON e.request_id=p.source_request_uid AND e.network_id=%d AND e.site_id=%d AND (e.actor_id=%d OR e.subject_id=%d OR (e.object_type='user' AND e.object_id=%d)) WHERE p.network_id=%d AND p.site_id=%d AND p.target_kind='ip' AND p.target_erased=0 ORDER BY p.id LIMIT %d FOR UPDATE", $policies, $events, $network, $site, $user, $user, $user, $network, $site, $limit ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'privacy', 'User-associated policy targets could not be selected.' ); } if ( $personal_ids ) { $placeholders = implode( ',', array_fill( 0, count( $personal_ids ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table is fixed and the IN grammar is generated only by array_fill placeholders; every selected ID or group key is passed separately to prepare. Erase the actual selected user-associated policy targets before removing their source joins. Three fixed %d placeholders for timestamp/network/site precede N generated policy-ID %s slots; all three integers then N selected IDs are supplied in order. if ( false === $wpdb->query( $wpdb->prepare( "UPDATE %i SET state='disabled',target_json=NULL,target_erased=1,compiled_hash='',source_request_uid=NULL,changed_at=%d WHERE network_id=%d AND site_id=%d AND id IN ({$placeholders})", $policies, time(), $network, $site, ...$personal_ids ) ) ) { return new \WP_Error( 'privacy', 'User-associated policy targets could not be erased.' ); } } $cases = self::erase_case_references( $network, $case_rows, $scope, $site ); if ( is_wp_error( $cases ) ) { return $cases; } if ( $ids ) { if ( class_exists( RequestsAutomaticPrivacy::class ) ) { $automatic = RequestsAutomaticPrivacy::erase_references( $network, $site, array_map( 'intval', $ids ), array(), $scope ); if ( is_wp_error( $automatic ) || ! $automatic['done'] ) { return is_wp_error( $automatic ) ? $automatic : new \WP_Error( 'privacy', 'User-linked automatic proof cleanup is pending; original rows retained.' ); } } $alerts = Alerts::erase_request_facts( $network, $site, array_map( 'intval', $ids ), $scope ); if ( is_wp_error( $alerts ) || ! $alerts['done'] ) { return is_wp_error( $alerts ) ? $alerts : new \WP_Error( 'privacy', 'Request alert cleanup is pending; original rows retained for retry.' ); } $safe_ids = implode( ',', array_fill( 0, count( array_map( 'absint', $ids ) ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- One generated %d token per normalized ID, followed by exactly that unpacked ID list; table, salt and scope have their own placeholders. WPCS cannot count the unpacked list. Update current identifiers only after dependent cleanup completes. $result = $wpdb->query( $wpdb->prepare( "UPDATE %i SET alert_state='skipped',client_mask=NULL,ip_mode='disabled',ip_expires_at=NULL,request_uid=LEFT(SHA2(CONCAT(request_uid,%s),256),32) WHERE network_id=%d AND site_id=%d AND id IN ({$safe_ids})", $requests, bin2hex( random_bytes( 16 ) ), $network, $site , ...array_map( 'absint', $ids ) ) ); if ( false === $result ) { return new \WP_Error( 'privacy', 'Linked request context could not be erased.' ); } } $policies = self::table( 'response_policies' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Lock the current policy creator rows before removing the forgotten operator identity. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND creator=%d ORDER BY id LIMIT %d FOR UPDATE", $policies, $network, $site, $user, $limit ), ARRAY_A ); if ( ! is_array( $rows ) || '' !== $wpdb->last_error ) { return new \WP_Error( 'privacy', 'Policy creator context could not be loaded.' ); } foreach ( $rows as $row ) { $valid = self::valid_policy( $row ); $row['creator'] = 0; // Existing path-policy semantics/expiry are preserved; drafts lose their principal. $changes = array( 'creator' => 0, 'compiled_hash' => empty( $row['target_erased'] ) ? self::compiled_hash( $row ) : '', ); if ( 'draft' === $row['state'] || ! $valid ) { $changes['state'] = 'disabled'; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist creator erasure and the resulting valid policy hash in the exact scoped policy. if ( false === $wpdb->update( $policies, $changes, array( 'network_id' => $network, 'site_id' => $site, 'id' => $row['id'], ) ) ) { return new \WP_Error( 'privacy', 'Policy creator context could not be erased.' ); } } $history = self::table( 'response_history' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Erase actual response-history actor IDs for the selected scoped user. $history_count = $wpdb->query( $wpdb->prepare( "UPDATE %i SET actor_id=0 WHERE network_id=%d AND site_id=%d AND actor_id=%d LIMIT %d", $history, $network, $site, $user, $limit ) ); if ( false === $history_count ) { return new \WP_Error( 'privacy', 'Policy history actor context could not be erased.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Fixed native event/request/policy tables and literal personal-target grammar surround the separately prepared scoped user predicate; remaining values use placeholders. Verify current remaining user-linked requests under lock before reporting erasure complete. $found = $wpdb->get_col( $wpdb->prepare( "SELECT r.id FROM %i r INNER JOIN %i e ON e.request_id=r.request_uid AND e.network_id=%d AND e.site_id=%d AND (e.actor_id=%d OR e.subject_id=%d OR (e.object_type='user' AND e.object_id=%d)) WHERE r.network_id=%d AND r.site_id=%d ORDER BY r.id LIMIT 501 FOR UPDATE", $requests, $events, $network, $site, $user, $user, $user, $network, $site ) ); /** * Validate linked request IDs before reporting personal erasure completion from the untyped wpdb adapter. * * @var mixed $found */ if ( ! is_array( $found ) || '' !== $wpdb->last_error ) { return new \WP_Error( 'privacy', 'Linked request erasure completion could not be verified.' ); } $remaining = count( $found ); foreach ( array( $policies => 'creator', $history => 'actor_id', ) as $table => $column ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The only table/column pairs are response_policies/creator and response_history/actor_id; network, site and user remain prepared values. Verify actual remaining creator and history-actor identities under lock. $found = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND %i=%d ORDER BY id LIMIT 501 FOR UPDATE", $table, $network, $site, $column, $user ) ); /** * Validate policy and history IDs before reporting personal erasure completion from the untyped wpdb adapter. * * @var mixed $found */ if ( ! is_array( $found ) || '' !== $wpdb->last_error ) { return new \WP_Error( 'privacy', 'Policy erasure completion could not be verified.' ); } $remaining += count( $found ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Fixed native event/request/policy tables and literal personal-target grammar surround the separately prepared scoped user predicate; remaining values use placeholders. Verify actual remaining user-linked personal targets before original event identities can be removed. $found = $wpdb->get_col( $wpdb->prepare( "SELECT p.id FROM %i p INNER JOIN %i e ON e.request_id=p.source_request_uid AND e.network_id=%d AND e.site_id=%d AND (e.actor_id=%d OR e.subject_id=%d OR (e.object_type='user' AND e.object_id=%d)) WHERE p.network_id=%d AND p.site_id=%d AND p.target_kind='ip' AND p.target_erased=0 ORDER BY p.id LIMIT 501 FOR UPDATE", $policies, $events, $network, $site, $user, $user, $user, $network, $site ) ); /** * Validate personal policy IDs before reporting personal erasure completion from the untyped wpdb adapter. * * @var mixed $found */ if ( ! is_array( $found ) || '' !== $wpdb->last_error ) { return new \WP_Error( 'privacy', 'Personal-policy erasure completion could not be verified.' ); } $remaining += count( $found ); return array( 'updated' => count( $ids ) + count( $rows ) + $history_count + count( $personal_ids ), 'remaining' => $remaining, 'done' => 0 === $remaining, ); }, static fn(): bool => $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_purge_data' ) ) ); } /** * Create an expiring policy draft from retained evidence in the authorized site. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param int $evidence Identifier of the retained request evidence in the selected site. * @param int $ttl Requested finite policy duration in seconds. * @param array|null $typed Optional structured conditions; null selects the frozen resource policy. * * @return array|\WP_Error Saved draft bound to its evidence and creator, or a validation or storage error. */ public static function preview( int $network, int $site, int $evidence, int $ttl, ?array $typed = null ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $scope = Scope::current( 'logged_ai_manage_response' ); if ( is_wp_error( $scope ) || $scope->network_id !== $network || $scope->site_id !== $site ) { return new \WP_Error( 'forbidden', 'Response authority for this site is required.' ); } if ( 'ip' === ( $typed['kind'] ?? '' ) && ! $scope->valid_for_all( array( 'logged_ai_manage_response', 'logged_ai_view_sensitive_context' ) ) ) { return new \WP_Error( 'sensitive_forbidden', 'Exact-source policies require sensitive-context and response authority.' ); } if ( ! in_array( $ttl, self::TTLS, true ) ) { return new \WP_Error( 'duration', 'Choose a supported finite duration.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read the currently retained exact source evidence before compiling a manual policy preview. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND id=%d AND captured_at>=%s', self::table( 'requests' ), $network, $site, $evidence, gmdate( 'Y-m-d H:i:s', time() - max( 1, (int) self::privacy_settings()['request_days'] ) * DAY_IN_SECONDS ) ), ARRAY_A ); if ( ! $row || '' !== $wpdb->last_error || 0 !== (int) $row['partial'] || ( null === $typed && ! in_array( $row['safe_path'], self::PATHS, true ) ) ) { return new \WP_Error( 'evidence', 'Complete retained evidence in this site is required for this exact policy.' ); } $policy = array( 'id' => bin2hex( random_bytes( 16 ) ), 'network_id' => $network, 'site_id' => $site, 'creator' => get_current_user_id(), 'state' => 'draft', 'origin' => 'manual', 'revision' => 1, 'parser_version' => self::PARSER, 'safe_path' => $row['safe_path'], 'methods' => 'GET,HEAD', 'ttl' => $ttl, 'evidence_id' => $evidence, 'created_at' => time(), 'starts_at' => 0, 'expires_at' => time() + 300, 'changed_at' => time(), ); if ( null !== $typed ) { $settings = self::privacy_settings(); $row = self::minimize_rows( array( $row ) )[0]; $row['settings_revision'] = (int) ( $settings['revision'] ?? 0 ); if ( 'ip' === ( $typed['kind'] ?? '' ) ) { $source = RequestsSource::get(); if ( is_wp_error( $source ) || ! hash_equals( $source['generation'], (string) ( $row['source_generation'] ?? RequestsSource::INITIAL_GENERATION ) ) || ! in_array( $row['source_quality'] ?? 'legacy_peer', array( 'exact', 'legacy_peer' ), true ) ) { return new \WP_Error( 'source_stale', 'Exact-source targeting needs complete evidence from the current source configuration.' ); } } $target = RequestsPolicy::compile( $typed, $row, $settings['ip_mode'] ); if ( is_wp_error( $target ) ) { return $target; } $policy['parser_version'] = RequestsPolicy::VERSION; $policy['target_kind'] = $target['kind']; $policy['target_json'] = wp_json_encode( $target ); $policy['target_erased'] = 0; $policy['target_expires_at'] = (int) ( $target['source_expires_at'] ?? 0 ); $policy['target_captured_at'] = (int) ( $target['source_captured_at'] ?? 0 ); $policy['target_settings_revision'] = (int) ( $target['settings_revision'] ?? 0 ); $policy['target_source_generation'] = $target['source_generation'] ?? RequestsSource::INITIAL_GENERATION; $policy['source_request_uid'] = 'ip' === $target['kind'] ? $row['request_uid'] : null; $policy['safe_path'] = 'ip' === $target['kind'] ? 'exact_direct_peer' : $target['path']; } $policy['compiled_hash'] = self::compiled_hash( $policy ); return self::locked( static function () use ( $policy, $network, $site, $scope ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! self::policy_authority( $scope, $policy ) ) { return new \WP_Error( 'forbidden', 'Policy preview authority changed.' ); } $table = self::table( 'response_policies' ); // One draft per operator/site; changing a preview invalidates the previous confirmation. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Invalidate actual prior or expired drafts under the request mutex before saving a new preview. if ( false === $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=%d AND state='draft' AND (creator=%d OR expires_at<%d)", $table, $network, $site, get_current_user_id(), time() ) ) ) { return new \WP_Error( 'save', 'Earlier previews could not be invalidated. No policy was activated.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Read the actual scoped draft count under the mutex before admitting another preview. $drafts = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d AND state='draft'", $table, $network, $site ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Installation draft capacity counts only the fixed response_policies table under the native prefix; this query has no request-supplied SQL values. Read the actual installation draft count under the mutex before admitting another preview. $all_drafts = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE state='draft'", $table ) ); if ( $drafts >= 100 || $all_drafts >= 500 || '' !== $wpdb->last_error ) { return new \WP_Error( 'limit', 'Preview capacity is unavailable. No policy was activated.' ); } if ( ! self::policy_authority( $scope, $policy ) ) { return new \WP_Error( 'forbidden', 'Policy preview authority changed.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Persist the exact creator-bound compiled preview only after current authority and capacity checks. if ( 1 !== $wpdb->insert( $table, $policy ) ) { return new \WP_Error( 'save', 'The preview could not be saved. No policy was activated.' ); } if ( ! self::policy_authority( $scope, $policy ) ) { return new \WP_Error( 'forbidden', 'Policy preview authority changed.' ); } return $policy; } ); } /** * Fail the selected activation and conditionally restore its prior response switch. * * @param array $policy Stored or compiled policy fields being inspected. * @param string $saved_state State saved by the activation attempt being rejected. * @param int $started_at Start timestamp bound to the activation being rejected. * @param array|null $response_before Previous response-switch state, when the attempt changed it. * * @return void */ private static function reject_activation( array $policy, string $saved_state, int $started_at, ?array $response_before = null ): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist failure only for the exact activation state and timestamps being rejected. $failed = $wpdb->update( self::table( 'response_policies' ), array( 'state' => 'failed', 'changed_at' => time(), ), array( 'id' => $policy['id'], 'network_id' => $policy['network_id'], 'site_id' => $policy['site_id'], 'state' => $saved_state, 'starts_at' => $started_at, 'expires_at' => $started_at + (int) $policy['ttl'], ) ); if ( null !== $response_before ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Conditionally restore the actual prior response switch after rejecting its activation. $restored = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET enabled=%d,revision=revision+1,last_change=%d WHERE network_id=%d AND site_id=%d AND revision=%d', self::table( 'response_state' ), (int) $response_before['enabled'], time(), (int) $policy['network_id'], (int) $policy['site_id'], (int) $response_before['revision'] + 1 ) ); if ( false === $restored ) { self::degraded( (int) $policy['network_id'], (int) $policy['site_id'], 'response_authority_gap' ); } } if ( false === $failed ) { self::degraded( (int) $policy['network_id'], (int) $policy['site_id'], 'response_authority_gap' ); } } /** * Confirm a current creator-bound draft for finite observation or activation. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param string $id Identifier of the current operator's draft policy. * @param string $hash Exact compiled hash shown by the current preview. * @param bool $activate Whether confirmation activates response or only observes matches. * * @return array|\WP_Error Saved policy state, or a validation, authority or storage error. */ public static function confirm( int $network, int $site, string $id, string $hash, bool $activate ): array|\WP_Error { $scope = Scope::current( 'logged_ai_manage_response' ); if ( is_wp_error( $scope ) || $scope->network_id !== $network || $scope->site_id !== $site ) { return new \WP_Error( 'forbidden', 'Response authority for this site is required.' ); } return self::locked( static function () use ( $network, $site, $id, $hash, $activate, $scope ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $table = self::table( 'response_policies' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Reload the actual current creator-bound draft before confirming its preview hash. $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND id=%s AND creator=%d", $table, $network, $site, $id, get_current_user_id() ), ARRAY_A ); if ( ! $row || 'draft' !== $row['state'] || (int) $row['expires_at'] <= time() || ! self::valid_policy( $row ) || ! hash_equals( $row['compiled_hash'], $hash ) ) { return new \WP_Error( 'stale', 'This preview is stale or changed. Preview the evidence again.' ); } if ( ! self::policy_authority( $scope, $row ) ) { return new \WP_Error( 'sensitive_forbidden', 'Policy confirmation authority is unavailable.' ); } if ( RequestsPolicy::VERSION === $row['parser_version'] ) { $settings = self::privacy_settings(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reload current retained evidence before confirming the typed policy target. $evidence = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND id=%d AND captured_at>=%s', self::table( 'requests' ), $network, $site, $row['evidence_id'], gmdate( 'Y-m-d H:i:s', time() - max( 1, (int) $settings['request_days'] ) * DAY_IN_SECONDS ) ), ARRAY_A ); if ( ! $evidence || '' !== $wpdb->last_error || 0 !== (int) $evidence['partial'] || RequestsPolicy::personal_unavailable( $row, $settings ) ) { return new \WP_Error( 'stale', 'Evidence or privacy settings changed. Create a fresh preview.' ); } $evidence = self::minimize_rows( array( $evidence ) )[0]; $evidence['settings_revision'] = (int) ( $settings['revision'] ?? 0 ); $target = RequestsPolicy::compile( json_decode( $row['target_json'], true, 8 ), $evidence, $settings['ip_mode'] ); if ( is_wp_error( $target ) || wp_json_encode( $target ) !== $row['target_json'] ) { return new \WP_Error( 'stale', 'The evidence no longer supports this exact target.' ); } } if ( $activate && self::recovery_disabled() ) { return new \WP_Error( 'recovery', 'The operator recovery constant disables response.' ); } $now = time(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Read actual unexpired scoped policy capacity before confirming activation or observation. $site_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d AND state IN ('active','observe') AND expires_at>%d", $table, $network, $site, $now ) ); // Installation aggregate only, never returned as other-site evidence. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifiers come from the fixed table-kind allowlist and native database prefix; runtime values use prepare placeholders. Read actual unexpired installation policy capacity before confirming another policy. $all_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE state IN ('active','observe') AND expires_at>%d", $table, $now ) ); if ( $site_count >= 100 || $all_count >= 500 || '' !== $wpdb->last_error ) { return new \WP_Error( 'limit', 'The finite policy capacity is unavailable. No policy was activated.' ); } if ( ! self::policy_authority( $scope, $row ) || ( $activate && ! self::enabling_allowed( $network, $site, $scope ) ) ) { return new \WP_Error( 'sensitive_forbidden', 'Enabling retained IP policies requires sensitive-context authority.' ); } $new_state = $activate ? 'active' : 'observe'; if ( ! self::audit( $network, $site, $activate ? 'activation_requested' : 'observe_requested', $id ) ) { return new \WP_Error( 'history', 'History could not be saved. No policy was activated.' ); } if ( ! self::policy_authority( $scope, $row ) ) { return new \WP_Error( 'forbidden', 'Policy confirmation authority changed.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist the exact draft to active or observe transition with its finite start and expiry. $ok = $wpdb->update( $table, array( 'state' => $new_state, 'starts_at' => $now, 'expires_at' => $now + (int) $row['ttl'], 'changed_at' => $now, ), array( 'id' => $id, 'network_id' => $network, 'site_id' => $site, 'state' => 'draft', ) ); if ( 1 !== $ok ) { return new \WP_Error( 'save', 'The policy could not be saved.' ); } if ( ! self::policy_authority( $scope, $row ) ) { self::reject_activation( $row, $new_state, $now ); return new \WP_Error( 'forbidden', 'Policy authority changed before activation completed.' ); } $response_before = null; $activation_caps = 'ip' === ( $row['target_kind'] ?? '' ) ? array( 'logged_ai_manage_response', 'logged_ai_view_sensitive_context' ) : array( 'logged_ai_manage_response' ); if ( $activate ) { $response_before = self::state( $network, $site ); $personal = null !== $response_before && 0 === (int) $response_before['enabled'] ? self::live_personal( $network, $site ) : false; if ( true === $personal ) { $activation_caps = array( 'logged_ai_manage_response', 'logged_ai_view_sensitive_context' ); } if ( null === $response_before || is_wp_error( $personal ) || ! $scope->valid_for_all( $activation_caps ) ) { self::reject_activation( $row, $new_state, $now ); return new \WP_Error( 'forbidden', 'Response authority changed before enabling.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomically enable actual response state only at its verified schema and previous revision. $changed = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET enabled=1,revision=revision+1,last_change=%d WHERE network_id=%d AND site_id=%d AND schema_version=%d AND revision=%d', self::table( 'response_state' ), $now, $network, $site, self::VERSION, (int) $response_before['revision'] ) ); if ( 1 !== $changed ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist the policy failure when the actual response switch could not be enabled. $wpdb->update( $table, array( 'state' => 'failed' ), array( 'id' => $id, 'network_id' => $network, 'site_id' => $site, ) ); return new \WP_Error( 'state', 'The response state could not be enabled. The policy failed.' ); } } if ( ! $scope->valid_for_all( $activation_caps ) ) { self::reject_activation( $row, $new_state, $now, $response_before ); return new \WP_Error( 'forbidden', 'Response authority changed during activation.' ); } if ( ! self::audit( $network, $site, $activate ? 'active_saved' : 'observe_saved', $id ) ) { self::degraded( $network, $site, 'response_history_gap' ); } if ( ! $scope->valid_for_all( $activation_caps ) ) { self::reject_activation( $row, $new_state, $now, $response_before ); return new \WP_Error( 'forbidden', 'Policy authority changed before confirmation completed.' ); } return array_merge( $row, array( 'state' => $new_state, 'starts_at' => $now, 'expires_at' => $now + (int) $row['ttl'], ) ); } ); } /** * Change the current-site response switch with additional checks before enabling. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param bool $enabled Requested response-switch state. * * @return bool Whether the state change completed with its required authority. */ public static function set_enabled( int $network, int $site, bool $enabled ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( Scope::network() !== $network || get_current_blog_id() !== $site ) { return false; } $principal = get_current_user_id(); $scope = $principal > 0 && $enabled ? Scope::current( 'logged_ai_manage_response' ) : null; if ( is_wp_error( $scope ) ) { return false; } $before = $enabled ? self::state( $network, $site ) : null; if ( $enabled && ( null === $before || ! self::enabling_allowed( $network, $site, $scope ) ) ) { return false; } $personal = $enabled && 0 === (int) $before['enabled'] ? self::live_personal( $network, $site ) : false; if ( is_wp_error( $personal ) ) { return false; } $required = $personal ? array( 'logged_ai_manage_response', 'logged_ai_view_sensitive_context' ) : array( 'logged_ai_manage_response' ); if ( $enabled && ( ( null === $scope && $personal ) || ( null !== $scope && ! $scope->valid_for_all( $required ) ) ) ) { return false; } // Emergency disable never waits for telemetry admission or a policy lock. if ( $enabled ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist current response state with its expected revision; a cache cannot provide the conditional write. $ok = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET enabled=1,revision=revision+1,last_change=%d WHERE network_id=%d AND site_id=%d AND schema_version=%d AND revision=%d', self::table( 'response_state' ), time(), $network, $site, self::VERSION, (int) $before['revision'] ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Emergency disable persists current state immediately without waiting for a policy lock or telemetry admission. $ok = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET enabled=0,revision=revision+1,last_change=%d WHERE network_id=%d AND site_id=%d AND schema_version=%d', self::table( 'response_state' ), time(), $network, $site, self::VERSION ) ); } if ( 1 !== $ok ) { return false; } $authority = ! $enabled || ( get_current_user_id() === $principal && Scope::network() === $network && get_current_blog_id() === $site && ( null === $scope || $scope->valid_for_all( $required ) ) ); if ( ! $authority ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Conditionally restore prior response state if enable authority changes before completion. $restored = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET enabled=%d,revision=revision+1,last_change=%d WHERE network_id=%d AND site_id=%d AND revision=%d', self::table( 'response_state' ), (int) $before['enabled'], time(), $network, $site, (int) $before['revision'] + 1 ) ); if ( false === $restored ) { self::degraded( $network, $site, 'response_authority_gap' ); } return false; } if ( ! self::audit( $network, $site, $enabled ? 'response_enabled' : 'response_disabled' ) ) { self::degraded( $network, $site, 'response_history_gap' ); } return true; } /** * Disable a selected policy through the shared revocation authority checks. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param string $id Exact identifier of the policy selected for revocation. * * @return bool Whether the selected revocation completed successfully. */ public static function revoke( int $network, int $site, string $id ): bool { return true === self::revocation( $network, $site, $id, false ); } /** * Preview the current revocation selection without changing its stored state. * * Preview one selected policy without exposing its target or changing history. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param string $id Exact identifier of the policy selected for preview. * * @return array|\WP_Error Bounded selection and effect facts, or an unavailable-policy error. */ public static function revoke_preview( int $network, int $site, string $id ): array|\WP_Error { $result = self::revocation( $network, $site, $id, true ); return is_array( $result ) ? $result : new \WP_Error( 'policy_unavailable', 'The selected policy is unavailable for revocation in this site.' ); } /** * Preview or execute revocation against the same current scoped selection. * * The preview and execution share current authority and the exact revocable-state selector. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param string $id Exact identifier of the policy selected for preview or revocation. * @param bool $dry_run Whether to return a bounded preview without executing the mutation. * * @return array|bool Preview facts or execution success; false when selection or authority fails. */ private static function revocation( int $network, int $site, string $id, bool $dry_run ): array|bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $connection = $wpdb; // This comparator performs no native lookups or authority callbacks after a // final permission decision. Never repair a callback's actual context drift. $context = static function () use ( $connection ): array { $user = $GLOBALS['current_user'] ?? null; $value = array( $GLOBALS['wpdb'] ?? null, get_current_blog_id(), $user instanceof \WP_User ? (int) $user->ID : 0, $GLOBALS['table_prefix'] ?? 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 ) { $value[ $field ] = $connection->{$field} ?? null; } return $value; }; $origin = $context(); if ( Scope::network() !== $network || get_current_blog_id() !== $site || ! preg_match( '/\A[a-f0-9]{32}\z/D', $id ) ) { return false; } $principal = get_current_user_id(); $scope = $principal > 0 ? Scope::current( 'logged_ai_manage_response' ) : null; if ( is_wp_error( $scope ) || ( null === $scope && ! ( defined( 'WP_CLI' ) && WP_CLI ) ) || $context() !== $origin ) { return false; } $authority = static fn(): bool => Scope::network() === $network && get_current_blog_id() === $site && get_current_user_id() === $principal && ( null === $scope || $scope->valid_for( 'logged_ai_manage_response' ) ) && $context() === $origin; // Policy revocation shares the issuance mutex. Recursive acquisition is balanced // by locked(), preserving an existing holder; busy callers make no policy change. $work = static function () use ( $network, $site, $id, $connection, $authority, $context, $origin, $dry_run ): array|bool { if ( $context() !== $origin || ! $authority() ) { return false; } if ( $dry_run ) { if ( ! class_exists( PreviewConnection::class, false ) ) { require_once __DIR__ . '/class-previewconnection.php'; } $table = self::table( 'response_policies' ); $result = PreviewConnection::run( static fn(): bool => $context() === $origin && $authority() && $context() === $origin, static function ( \wpdb $reader ) use ( $table, $network, $site, $id ): array|\WP_Error { $rows = PreviewConnection::rows( $reader, $reader->prepare( "SELECT id,state FROM %i WHERE network_id=%d AND site_id=%d AND id=%s AND state IN ('draft','active','observe','failed') LIMIT 1 FOR UPDATE", $table, $network, $site, $id ), array( 'id', 'state' ), 1 ); $row = is_array( $rows ) && 1 === count( $rows ) ? $rows[0] : null; if ( ! is_array( $row ) || ( $row['id'] ?? null ) !== $id || ! in_array( $row['state'] ?? null, array( 'draft', 'active', 'observe', 'failed' ), true ) ) { return new \WP_Error( 'policy_unavailable', 'The selected policy is unavailable for revocation in this site.' ); } return array( 'action' => 'revoke_preview', 'dry_run' => true, 'network_id' => (string) $network, 'site_id' => (string) $site, 'policy_id' => $id, 'selected_rows' => 1, 'effect' => 'A confirmed invocation disables this selected policy and attempts to append its revocation audit entry. Existing request evidence and policy history are retained.', 'selection_semantics' => 'One policy in its current stored state; this preview reserves no future change and makes no claim about whether the policy is currently enforcing.', ); }, array( $table ) ); return is_array( $result ) && $context() === $origin ? $result : false; } $ok = $connection->query( $connection->prepare( "UPDATE %i SET state='disabled',changed_at=%d WHERE network_id=%d AND site_id=%d AND id=%s AND state IN ('draft','active','observe','failed')", self::table( 'response_policies' ), time(), $network, $site, $id ) ); if ( 1 !== $ok ) { return false; } if ( $context() !== $origin || ! $authority() ) { return false; } if ( ! self::audit( $network, $site, 'revoked', $id ) ) { self::degraded( $network, $site, 'response_history_gap' ); } return $authority(); }; if ( $dry_run ) { $name = self::lock_name(); $acquired = self::preview_lock_statement( $connection, $context, $origin, $name, false ); if ( '1' !== (string) $acquired['value'] || $acquired['changed'] ) { if ( '1' === (string) $acquired['value'] ) { self::preview_lock_statement( $connection, $context, $origin, $name, true ); } return false; } // Admission balances only this invocation's recursive caller lease before // the independent current read. The preview reserves no future mutation. $release = self::preview_lock_statement( $connection, $context, $origin, $name, true ); if ( '1' !== (string) $release['value'] || $release['changed'] || '' !== $connection->last_error || $context() !== $origin || ! $authority() ) { return false; } $result = $work(); // Private row locks cover the helper's last authorization callback; do not // introduce caller queries or another grant callback after that seal. return is_array( $result ) && $context() === $origin ? $result : false; } $result = self::locked( $work ); return true === $result && $context() === $origin; } /** * Bind a preview mutex statement to its original connection, context and key. * * Bind only preview lease SQL; execution retains its established lock path. * * @param \wpdb $db Original database connection owning the preview mutex. * @param callable $context Callback returning the current native context for exact comparison. * @param array $origin Native context captured before the preview lock statement. * @param string $key Original installation mutex key. * @param bool $release Whether to release this invocation's lease instead of acquiring it. * * @return array Observed scalar result and a flag recording any statement or context drift. */ private static function preview_lock_statement( \wpdb $db, callable $context, array $origin, string $key, bool $release ): array { $expected = $db->remove_placeholder_escape( $db->prepare( $release ? 'SELECT RELEASE_LOCK(%s)' : 'SELECT GET_LOCK(%s,0)', $key ) ); $changed = false; $observe = static function ( string $sql ) use ( $db, $context, $origin, $expected, $release, &$changed ): string { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 12 live frames to bind the original preview mutex statement to its owning connection and reject rewrites; no trace is emitted or persisted. foreach ( debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 12 ) as $frame ) { if ( 'query' !== $frame['function'] || ! ( $frame['object'] ?? null ) instanceof \wpdb ) { continue; } $original = $frame['args'][0] ?? null; if ( $frame['object'] === $db && is_string( $original ) && $db->remove_placeholder_escape( $original ) === $expected ) { if ( $sql !== $expected || $context() !== $origin ) { $changed = true; // Release only the original key on its owning connection, even // after drift; a corrected rewrite never verifies public success. return $release ? $expected : ''; } } break; } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); try { // A filtered empty query returns before native wpdb clears last_result. // Read a scalar only after this exact query actually selected one row. $queried = $db->query( $expected ); $value = 1 === $queried ? $db->get_var( null ) : null; } catch ( \Throwable $error ) { $value = null; $changed = true; } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } return array( 'value' => $value, 'changed' => $changed || $context() !== $origin, ); } }