1, 'category' => 'repeated_resource_probing', 'methods' => array( 'GET', 'HEAD' ), 'enabled' => 'observe', 'automatic_response_eligible' => false, 'confidence' => 'observed_pattern_only', 'priority' => 'review', 'bounded_rows' => self::ROW_LIMIT, 'window_seconds' => self::WINDOW_SECONDS, 'benign_overlap' => 'Authorized scanners, diagnostic tools and unrelated visitors can produce this pattern. A direct network peer may represent many people.', 'reference' => 'Bundled REQUEST_INTELLIGENCE.md R6/R8; no exploit/version applicability is asserted.', ); return array( 'repeated_reserved_paths' => $shared + array( 'id' => 'core.repeated-reserved-paths', 'label' => 'Repeated distinct reserved-resource requests', 'reason' => 'At least 10 complete admitted GET/HEAD findings cover at least three distinct safe reserved-resource templates in one fixed UTC minute.', 'preconditions' => 'Known configuration/source/diagnostic templates; complete parser result; this authorized site and source label. Status can be unknown.', ), 'repeated_reserved_404' => $shared + array( 'id' => 'core.repeated-reserved-404', 'label' => 'Repeated distinct reserved resources with observed 404s', 'reason' => 'At least 10 complete admitted GET/HEAD findings have a finalized observed 404 across at least three distinct safe reserved-resource templates in one fixed UTC minute.', 'preconditions' => 'Same safe-template preconditions; actual final status=404 and recorded response time. Missing/unknown/soft-404 statuses do not qualify.', ), ); } /** * Read minimized retained observations and project sequence findings through current grants. * * Uses the canonical minimized read, so setting changes/expiry apply before source grouping. * * @param Scope $scope Current native authorization scope selected for this operation. * * @return array|\WP_Error Bounded findings and scan statistics, or a read/authority error. */ public static function scan( Scope $scope ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_view_events' ) ) { return new \WP_Error( 'forbidden', 'Request-sequence access is unavailable.' ); } $rows = Requests::observations( $scope->network_id, $scope->site_id, self::ROW_LIMIT ); if ( is_wp_error( $rows ) ) { return $rows; } $result = self::reduce( $rows, Requests::privacy_settings(), time(), $scope->network_id, $scope->site_id ); if ( ! $scope->valid_for( 'logged_ai_view_events' ) ) { return new \WP_Error( 'forbidden', 'Request-sequence authority changed.' ); } if ( ! $scope->valid_for( 'logged_ai_view_sensitive_context' ) ) { $result['findings'] = array_values( array_filter( $result['findings'], static fn( array $finding ): bool => 'site_window' === $finding['scope'] ) ); } return $result; } /** * Group bounded eligible retained rows into fixed site and permitted exact-source windows. * * Pure reduction: source keys exist only during this call and must never be persisted. * * @param array $rows Bounded retained source rows to validate and summarize. * @param array $settings Current request-retention, address-privacy and source-generation settings. * @param int $now Current Unix timestamp supplied for interval and retention decisions. * @param int $network Server-selected native network identifier. * @param int $site Server-selected native site identifier. * * @return array Sampled findings, lower-bound counters and explicit analysis limits. */ public static function reduce( array $rows, array $settings, int $now, int $network, int $site ): array { $start = intdiv( $now, self::WINDOW_SECONDS ) * self::WINDOW_SECONDS - ( self::WINDOWS - 1 ) * self::WINDOW_SECONDS; $retention = max( 1, (int) ( $settings['request_days'] ?? 7 ) ) * DAY_IN_SECONDS; $exact_retention = max( 1, (int) ( $settings['exact_ip_days'] ?? 14 ) ) * DAY_IN_SECONDS; $groups = array(); $seen = array(); $stats = array( 'rows_examined' => 0, 'eligible_observations' => 0, 'partial_analysis_excluded' => 0, 'unknown_final_status' => 0, 'exact_source_unavailable' => 0, ); $safe_paths = RequestsClassifier::safe_paths(); foreach ( array_slice( $rows, 0, self::ROW_LIMIT ) as $row ) { ++$stats['rows_examined']; // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- This lowercase versioned source/component identifier is persisted protocol data, not a displayed WordPress name; changing case would break matching and retained records. if ( ! is_array( $row ) || (string) ( $row['network_id'] ?? '' ) !== (string) $network || (string) ( $row['site_id'] ?? '' ) !== (string) $site || 'wordpress' !== ( $row['sensor'] ?? '' ) || ! in_array( $row['source'] ?? '', array( 'wordpress', 'synthetic_test' ), true ) ) { continue; } $id = (string) ( $row['id'] ?? '' ); if ( ! preg_match( '/^[1-9][0-9]{0,19}$/D', $id ) || isset( $seen[ $id ] ) ) { continue; } $seen[ $id ] = true; $captured = self::timestamp( $row['captured_at'] ?? null ); $path = $row['safe_path'] ?? ''; if ( null === $captured || $captured < max( $start, $now - $retention ) || $captured > $now || ! is_string( $path ) || ! in_array( $path, $safe_paths, true ) || ! in_array( $row['family'] ?? '', array( 'reserved_configuration', 'source_diagnostic' ), true ) || ! in_array( $row['method'] ?? '', array( 'GET', 'HEAD' ), true ) ) { continue; } $window = intdiv( $captured, self::WINDOW_SECONDS ) * self::WINDOW_SECONDS; $complete = '0' === (string) ( $row['partial'] ?? '' ); $status = null; $response_time = self::timestamp( $row['response_at'] ?? null ); $raw_status = (string) ( $row['status'] ?? '' ); if ( null !== $response_time && $response_time >= $captured && $response_time <= $now && preg_match( '/^[1-5][0-9]{2}$/D', $raw_status ) ) { $status = (int) $raw_status; } if ( ! $complete ) { ++$stats['partial_analysis_excluded']; } if ( null === $status ) { ++$stats['unknown_final_status']; } if ( $complete ) { ++$stats['eligible_observations']; } $base_key = $window . ':' . $row['source']; $keys = array( $base_key . ':site' => null ); $peer = $row['client_mask'] ?? null; $generation = $row['source_generation'] ?? '00000000000000000000000000000000'; $current_generation = array_key_exists( 'source_generation', $settings ) ? $settings['source_generation'] : '00000000000000000000000000000000'; $derivation = $row['source_derivation'] ?? 'direct_peer'; if ( is_string( $generation ) && $generation === $current_generation && in_array( $derivation, array( 'direct_peer', 'trusted_proxy' ), true ) && in_array( $row['source_quality'] ?? 'legacy_peer', array( 'exact', 'legacy_peer' ), true ) && 'full' === ( $settings['ip_mode'] ?? 'masked' ) && 'full' === ( $row['ip_mode'] ?? '' ) && (int) ( $row['ip_expires_at'] ?? 0 ) > $now && $captured + $exact_retention > $now && is_string( $peer ) && strlen( $peer ) <= 45 && filter_var( $peer, FILTER_VALIDATE_IP ) ) { $peer = inet_ntop( inet_pton( $peer ) ); $keys[ $base_key . ':peer:' . $generation . ':' . $derivation . ':' . $peer ] = $peer; } else { ++$stats['exact_source_unavailable']; } foreach ( $keys as $key => $exact_peer ) { if ( ! isset( $groups[ $key ] ) ) { $groups[ $key ] = array( 'window_start' => $window, 'source' => $row['source'], 'scope' => null === $exact_peer ? 'site_window' : 'exact_source_window', 'source_address' => $exact_peer, 'source_derivation' => null === $exact_peer ? 'unavailable' : $derivation, 'first_at' => $captured, 'last_at' => $captured, 'admitted_count' => 0, 'eligible_count' => 0, 'partial_count' => 0, 'unknown_status_count' => 0, 'known_404_count' => 0, 'recorded_local_responses' => 0, 'status_distribution' => array( '1xx' => 0, '2xx' => 0, '3xx' => 0, '4xx' => 0, '5xx' => 0, 'unknown' => 0, ), 'paths' => array(), 'paths_404' => array(), 'families' => array(), 'sample_ids' => array(), ); } $group = $groups[ $key ]; $group['first_at'] = min( $group['first_at'], $captured ); $group['last_at'] = max( $group['last_at'], $captured ); ++$group['admitted_count']; $group['eligible_count'] += $complete ? 1 : 0; $group['partial_count'] += $complete ? 0 : 1; $group['unknown_status_count'] += null === $status ? 1 : 0; ++$group['status_distribution'][ null === $status ? 'unknown' : intdiv( $status, 100 ) . 'xx' ]; $group['recorded_local_responses'] += 'wordpress_response_issued' === ( $row['own_response'] ?? '' ) ? 1 : 0; if ( $complete ) { $group['paths'][ $path ] = true; $group['families'][ $row['family'] ] = true; if ( 404 === $status ) { ++$group['known_404_count']; $group['paths_404'][ $path ] = true; } if ( count( $group['sample_ids'] ) < 5 ) { $group['sample_ids'][] = $id; } } $groups[ $key ] = $group; unset( $group ); } } $findings = array(); foreach ( $groups as $group ) { if ( $group['eligible_count'] < self::MIN_OBSERVATIONS || count( $group['paths'] ) < self::MIN_DISTINCT_PATHS ) { continue; } $group['rules'] = array( 'repeated_reserved_paths' ); if ( $group['known_404_count'] >= self::MIN_OBSERVATIONS && count( $group['paths_404'] ) >= self::MIN_DISTINCT_PATHS ) { $group['rules'][] = 'repeated_reserved_404'; } $group['safe_templates'] = array_keys( $group['paths'] ); $group['safe_404_templates'] = array_keys( $group['paths_404'] ); $group['families'] = array_keys( $group['families'] ); sort( $group['safe_templates'], SORT_STRING ); sort( $group['safe_404_templates'], SORT_STRING ); sort( $group['families'], SORT_STRING ); unset( $group['paths'], $group['paths_404'] ); $group['window_end'] = $group['window_start'] + self::WINDOW_SECONDS; $group['time_window_partial'] = $group['window_end'] > $now; $group['dataset_sampled'] = true; $group['effect'] = 'unknown'; $group['automatic_response_eligible'] = false; $findings[] = $group; } usort( $findings, static function ( array $a, array $b ): int { $comparison = $b['window_start'] <=> $a['window_start']; if ( 0 !== $comparison ) { return $comparison; } $comparison = strcmp( $a['scope'], $b['scope'] ); if ( 0 !== $comparison ) { return $comparison; } return strcmp( $a['source'], $b['source'] ); } ); return array( 'version' => self::VERSION, 'network_id' => $network, 'site_id' => $site, 'generated_at' => $now, 'window_seconds' => self::WINDOW_SECONDS, 'window_start' => $start, 'window_end' => $now, 'row_limit' => self::ROW_LIMIT, 'row_limit_reached' => count( $rows ) >= self::ROW_LIMIT, 'dataset_sampled' => true, 'stats' => $stats, 'findings' => $findings, 'interpretation' => 'Current retained admitted findings only. Counts are lower bounds; missing telemetry is not quantifiable. Fixed UTC-minute boundaries can split a sequence. Site and exact-peer views overlap and must not be summed. No attacker identity, historical alert, exposure, prevention or automated-response eligibility is established.', ); } /** * Validate an exact stored UTC timestamp without fractional seconds. * * @param mixed $value Supplied value to inspect without narrowing its native input contract. * * @return int|null Parsed Unix timestamp, or null for an unsupported representation. */ private static function timestamp( mixed $value ): ?int { if ( ! is_string( $value ) || ! preg_match( '/^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/D', $value ) ) { return null; } $time = strtotime( $value . ' UTC' ); return false !== $time && gmdate( 'Y-m-d H:i:s', $time ) === $value ? $time : null; } /** * Render current bounded sequence findings with per-finding access checks. * * Optional admin integration; read only, with its own authorization check. * * @param Scope $scope Current native authorization scope selected for this operation. * @param TimeDisplay $time Configured display-time helper for rendered timestamps. * * @return void */ public static function render( Scope $scope, TimeDisplay $time ): void { // Resolve translation callbacks before reading authorized request evidence. $display_text = array( 0 => __( 'Recent repeated-resource patterns', 'logged-ai' ), /* translators: Values or formatting elements shown in this message. */ 1 => __( 'Examined %1$s of at most 500 current retained rows across five fixed one-minute windows. Row limit reached: %2$s. Partial analyses excluded from thresholds: %3$s. Unknown final statuses: %4$s.', 'logged-ai' ), 2 => __( 'yes; additional rows unavailable', 'logged-ai' ), 3 => __( 'no', 'logged-ai' ), 4 => __( 'No qualifying pattern in this bounded retained view. This does not establish an absence of probing or missing requests.', 'logged-ai' ), 5 => __( 'Site aggregate; source association unavailable. Different visitors may contribute.', 'logged-ai' ), /* translators: Values or formatting elements shown in this message. */ 6 => __( 'Exact source context (%1$s): %2$s. This can still be a shared proxy or network, not one person.', 'logged-ai' ), 7 => __( 'yes', 'logged-ai' ), 8 => __( 'unknown', 'logged-ai' ), 9 => __( 'Authorized scanners and diagnostics can produce this pattern. Ordinary missing assets and unavailable query text do not enter these thresholds. This read model creates no alert or response policy.', 'logged-ai' ), ); $display_labels = PresentationTranslations::snapshot( array( 'Manual policy response decision', 'A deliberately configured manual condition matched; this is a response decision, not an attack finding.', 'Ordinary benign requests can match a manual condition. No attack classification is inferred from this response.', 'Configuration-resource probe pattern', 'Exact reserved configuration filename or recognized backup variant.', 'An authorized scanner or broken integration can request these resources.', 'Source-control or diagnostic discovery', 'Exact source-control metadata or diagnostic resource template.', 'Authorized diagnostics can deliberately request these resources.', 'Traversal-shaped resource request', 'Two parent-directory segments precede a recognized configuration/system-resource suffix.', 'Malformed links and authorized scanners are possible; target application normalization is unknown.', 'SQL-shaped numeric parameter', 'A numeric-identifier parameter contains a compound boolean or UNION projection with a comment delimiter.', 'A test or code example can contain the same structure; no execution or intent is established.', 'Executable-markup-shaped parameter', 'A numeric/navigation parameter contains paired script markup, an event-handler tag, or an executable URL scheme.', 'Authorized testing can contain the same syntax. Editing/search content parameters are not classified.', 'Component metadata enumeration', 'A plugin readme or theme metadata path was requested; enumeration is not exploitation.', 'Documentation tools and legitimate theme stylesheets can use related routes; theme style.css is excluded.', 'Unusual method at a non-API resource', 'TRACE/TRACK/CONNECT or a write method was used on a reserved metadata/configuration resource.', 'Protocol/security tests may intentionally exercise methods; ordinary API writes and OPTIONS are excluded.', 'Partial analysis', 'Application effect', 'Authorized scanners, diagnostic tools and unrelated visitors can produce this pattern. A direct network peer may represent many people.', 'Repeated distinct reserved-resource requests', 'At least 10 complete admitted GET/HEAD findings cover at least three distinct safe reserved-resource templates in one fixed UTC minute.', 'Known configuration/source/diagnostic templates; complete parser result; this authorized site and source label. Status can be unknown.', 'Repeated distinct reserved resources with observed 404s', 'At least 10 complete admitted GET/HEAD findings have a finalized observed 404 across at least three distinct safe reserved-resource templates in one fixed UTC minute.', 'Request-sequence access is unavailable.', 'Request-sequence authority changed.', 'Current retained admitted findings only. Counts are lower bounds; missing telemetry is not quantifiable. Fixed UTC-minute boundaries can split a sequence. Site and exact-peer views overlap and must not be summed. No attacker identity, historical alert, exposure, prevention or automated-response eligibility is established.', 'Admitted / complete eligible', 'First / last (display timezone)', 'Safe templates', 'Known finalized 404s', 'Recorded local responses', 'Unknown final status', 'Window still open', 'Sample observation IDs', ) ); $result = self::scan( $scope ); echo ( '

' . esc_html( $display_text[ 0 ] ) . '

' ); if ( is_wp_error( $result ) ) { echo ( '

' . ( esc_html( PresentationTranslations::text( $result->get_error_message() ) ) ) . '

' ); return; } echo ( '

' . ( esc_html( PresentationTranslations::lookup( $display_labels, $result['interpretation'] ) ) ) . '

' . '

' . sprintf( /* translators: 1: Value or formatting element shown in this message. 2: Value or formatting element shown in this message. 3: Value or formatting element shown in this message. 4: Value or formatting element shown in this message. */ esc_html( $display_text[ 1 ] ), esc_html( (string) $result['stats']['rows_examined'] ), esc_html( ( $result['row_limit_reached'] ? $display_text[ 2 ] : $display_text[ 3 ] ) ), esc_html( (string) $result['stats']['partial_analysis_excluded'] ), esc_html( (string) $result['stats']['unknown_final_status'] ) ) . '

' ); if ( ! $result['findings'] ) { echo ( '

' . esc_html( $display_text[ 4 ] ) . '

' ); } foreach ( $result['findings'] as $finding ) { if ( ! $scope->valid_for( 'logged_ai_view_events' ) ) { return; } if ( 'site_window' !== $finding['scope'] && ! $scope->valid_for( 'logged_ai_view_sensitive_context' ) ) { continue; } echo ( '
' . '' . ( esc_html( implode( ' / ', array_map( static fn( string $key ): string => PresentationTranslations::lookup( $display_labels, self::rules()[ $key ]['label'] ), $finding['rules'] ) ) ) ) . ' · ' . ( esc_html( $time->text( $finding['window_start'] ) ) ) . ' · ' . ( esc_html( $finding['source'] ) ) . '' . '

' . ( esc_html( ( 'site_window' === $finding['scope'] ? $display_text[ 5 ] : sprintf( /* translators: 1: Value shown in this message. 2: Value shown in this message. */ $display_text[ 6 ], $finding['source_derivation'], $finding['source_address'] ) ) ) ) . '

' . '
' ); foreach ( array( 'Admitted / complete eligible' => $finding['admitted_count'] . ' / ' . $finding['eligible_count'], 'First / last (display timezone)' => $time->text( $finding['first_at'] ) . ' / ' . $time->text( $finding['last_at'] ), 'Safe templates' => implode( ', ', $finding['safe_templates'] ), 'Known finalized 404s' => $finding['known_404_count'], 'Recorded local responses' => $finding['recorded_local_responses'], 'Unknown final status' => $finding['unknown_status_count'], 'Partial analysis' => $finding['partial_count'], 'Window still open' => $finding['time_window_partial'] ? $display_text[ 7 ] : $display_text[ 3 ], 'Sample observation IDs' => implode( ', ', $finding['sample_ids'] ), 'Application effect' => $display_text[ 8 ], ) as $key => $value ) { echo '
' . esc_html( PresentationTranslations::lookup( $display_labels, $key ) ) . '
' . esc_html( (string) $value ) . '
'; } echo ( '
' . '

' . esc_html( $display_text[ 9 ] ) . '

' . '
' ); } } }