event_store = $event_store; $this->settings_repository = $settings_repository; $this->hasher = $hasher; } /** * Log a decision and always return the original decision. * * @param string $source_key Source key. * @param SubmissionContext $context Submission context. * @param SpamDecision $decision Decision. */ public function log_decision( string $source_key, SubmissionContext $context, SpamDecision $decision ): SpamDecision { try { $this->store_decision( $source_key, $context, $decision ); } catch ( \Throwable $throwable ) { unset( $throwable ); } return $decision; } /** * Store a decision when settings allow it. * * @param string $source_key Source key. * @param SubmissionContext $context Submission context. * @param SpamDecision $decision Decision. */ private function store_decision( string $source_key, SubmissionContext $context, SpamDecision $decision ): void { $settings = $this->settings_repository->get_settings(); if ( empty( $settings['event_logging_enabled'] ) || SettingsSnapshot::MODE_DISABLED === (string) $settings['protection_mode'] ) { return; } $action = $this->get_action_from_decision( $decision ); if ( SpamEvent::ACTION_ALLOWED === $action && empty( $settings['log_allowed_events'] ) ) { return; } $event = SpamEvent::from_array( array( 'event_id' => $this->create_event_id( $source_key, $context, $decision ), 'created_at' => $context->get_timestamp(), 'source_key' => $source_key, 'form_id' => $context->get_form_id(), 'action' => $action, 'score' => $this->get_score_from_action( $action ), 'reason_codes' => $decision->get_reason_codes(), 'ip_hash' => $this->hash_if_present( 'ip', $context->get_rate_limit_identifier() ), 'email_hash' => '', 'user_agent_hash' => $this->hash_if_present( 'user_agent', $this->get_user_agent_identifier() ), 'context_hash' => $this->hash_if_present( 'context', $source_key . '|' . $context->get_form_id() . '|' . implode( ',', $decision->get_reason_codes() ) ), ) ); $this->event_store->add_event( $event, (int) $settings['event_retention_days'], (int) $settings['max_event_records'] ); } /** * Map decision to stored action. * * @param SpamDecision $decision Decision. */ private function get_action_from_decision( SpamDecision $decision ): string { if ( $decision->should_block() ) { return SpamEvent::ACTION_BLOCKED; } if ( $decision->is_suspicious() ) { return SpamEvent::ACTION_SUSPICIOUS; } return SpamEvent::ACTION_ALLOWED; } /** * Get a simple score from action. * * @param string $action Action. */ private function get_score_from_action( string $action ): int { if ( SpamEvent::ACTION_BLOCKED === $action ) { return 100; } if ( SpamEvent::ACTION_SUSPICIOUS === $action ) { return 50; } return 0; } /** * Hash an identifier when present. * * @param string $purpose Purpose. * @param string $identifier Identifier. */ private function hash_if_present( string $purpose, string $identifier ): string { if ( '' === trim( $identifier ) ) { return ''; } return $this->hasher->hash_identifier( $purpose, $identifier ); } /** * Create event ID without using stored personal data. * * @param string $source_key Source key. * @param SubmissionContext $context Submission context. * @param SpamDecision $decision Decision. */ private function create_event_id( string $source_key, SubmissionContext $context, SpamDecision $decision ): string { return hash( 'sha256', implode( '|', array( microtime( true ), $source_key, $context->get_form_id(), $decision->get_verdict() ) ) ); } /** * Get user agent only for immediate hashing. */ private function get_user_agent_identifier(): string { if ( ! isset( $_SERVER['HTTP_USER_AGENT'] ) || ! is_scalar( $_SERVER['HTTP_USER_AGENT'] ) ) { return ''; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Value is sanitized and immediately hashed before storage. return sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ); } }