$context Raw context. * * @return array */ public function context( array $context ): array { $context = apply_filters( 'adminoptim_logger_context', $context ); if ( ! is_array( $context ) ) { $context = []; } $sanitized = $this->sanitize_array( $context, 0 ); $encoded = wp_json_encode( $sanitized ); if ( is_string( $encoded ) && strlen( $encoded ) > self::MAX_CONTEXT_BYTES ) { $sanitized = [ '_truncated' => true, 'preview' => $this->truncate( $encoded, self::MAX_CONTEXT_BYTES - 64 ), ]; } return $sanitized; } /** * Produces a stable fingerprint from the dedup identity. * * @param string $level Log level. * @param string $channel Log channel. * @param string $event Channel-relative event name. * @param array $context Sanitized context. * * @return string */ public function fingerprint( string $level, string $channel, string $event, array $context ): string { $normalized = $this->normalize( $context ); $encoded = wp_json_encode( $normalized ); return hash( 'sha256', implode( "\n", [ $level, $channel, $event, is_string( $encoded ) ? $encoded : '{}' ] ) ); } /** * Returns only a sanitized path from a request URI. * * @param string $uri Raw request URI. * * @return string|null */ public function request_path( string $uri ): ?string { $path = wp_parse_url( $uri, PHP_URL_PATH ); if ( ! is_string( $path ) || '' === $path ) { return null; } $path = preg_replace( '/[^\x20-\x7E]/', '', $path ); $path = is_string( $path ) ? $this->truncate( $path, 500 ) : ''; return '' === $path ? null : $path; } /** * Sanitizes a message for storage. * * @param mixed $message Raw message value. * * @return string */ public function message( $message ): string { if ( ! is_scalar( $message ) && ! ( is_object( $message ) && method_exists( $message, '__toString' ) ) ) { return '[unsupported value]'; } return $this->mask_emails( $this->truncate( wp_strip_all_tags( (string) $message ), 65535 ) ); } /** * Makes a file path relative to the WordPress root. * * @param string $path Absolute file path. * * @return string */ public function relative_path( string $path ): string { $normalized = str_replace( '\\', '/', $path ); $root = defined( 'ABSPATH' ) ? rtrim( str_replace( '\\', '/', ABSPATH ), '/' ) . '/' : ''; return $root && 0 === strpos( $normalized, $root ) ? substr( $normalized, strlen( $root ) ) : basename( $normalized ); } /** * Recursively sanitizes arrays. * * @param array $values Values to sanitize. * @return array */ private function sanitize_array( array $values, int $depth ): array { if ( $depth >= self::MAX_DEPTH ) { return [ '[maximum depth reached]' ]; } $result = []; foreach ( array_slice( $values, 0, 100, true ) as $key => $value ) { $key = is_int( $key ) ? $key : $this->truncate( (string) $key, 150 ); if ( is_string( $key ) && ( $this->is_sensitive_key( $key ) || $this->is_forbidden_payload_key( $key ) ) ) { $result[ $key ] = '[redacted]'; continue; } $result[ $key ] = $this->sanitize_value( $value, $depth + 1 ); } if ( count( $values ) > 100 ) { $result['_truncated_items'] = count( $values ) - 100; } return $result; } /** * Sanitizes one context value. * * @param mixed $value Raw value. * @param int $depth Current recursion depth. * * @return mixed */ private function sanitize_value( $value, int $depth ) { if ( is_array( $value ) ) { return $this->sanitize_array( $value, $depth ); } if ( is_string( $value ) ) { if ( $this->is_binary( $value ) ) { return '[binary data removed]'; } return $this->mask_emails( $this->truncate( $value, self::MAX_VALUE_BYTES ) ); } if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) || null === $value ) { return $value; } if ( is_resource( $value ) ) { return '[resource removed]'; } if ( $value instanceof \Closure ) { return '[closure removed]'; } if ( is_object( $value ) && method_exists( $value, '__toString' ) ) { return $this->mask_emails( $this->truncate( (string) $value, self::MAX_VALUE_BYTES ) ); } return '[unsupported object removed]'; } /** * Whether a context key name looks like it holds a secret (password, token, etc.). * * @param string $key Context array key being checked. * @return bool */ private function is_sensitive_key( string $key ): bool { $keys = [ 'password', 'passwd', 'secret', 'token', 'api_key', 'authorization', 'cookie', 'smtp_pass', 'access_key', 'private_key', 'nonce', ]; $keys = apply_filters( 'adminoptim_logger_sensitive_keys', $keys ); $key = strtolower( $key ); foreach ( is_array( $keys ) ? $keys : [] as $sensitive ) { if ( '' !== (string) $sensitive && false !== strpos( $key, strtolower( (string) $sensitive ) ) ) { return true; } } return false; } /** * Whether a context key name holds a bulk payload that should never be logged in full. * * @param string $key Context array key being checked. * @return bool */ private function is_forbidden_payload_key( string $key ): bool { return in_array( strtolower( $key ), [ 'body', 'request_body', 'email_content', 'email_body', 'form_submission', 'submission_data', 'ai_prompt', 'ai_response', 'prompt', ], true ); } /** * Whether a string contains a null byte or invalid UTF-8, and so isn't safe to log as text. * * @param string $value Value being checked. * @return bool */ private function is_binary( string $value ): bool { return false !== strpos( $value, "\0" ) || ! preg_match( '//u', $value ); } /** * Redacts the local part of any email address found in a string. * * @param string $value Value to mask. * @return string */ private function mask_emails( string $value ): string { return (string) preg_replace_callback( '/\b([A-Z0-9._%+\-])[A-Z0-9._%+\-]*@([A-Z0-9.\-]+\.[A-Z]{2,})\b/i', static function ( array $matches ): string { return $matches[1] . '***@' . $matches[2]; }, $value ); } /** * Truncates a string to a byte limit, appending a '[truncated]' marker when cut. * * @param string $value Value to truncate. * @param int $limit Maximum length before truncation. * @return string */ private function truncate( string $value, int $limit ): string { if ( strlen( $value ) <= $limit ) { return $value; } return substr( $value, 0, max( 0, $limit - 13 ) ) . '[truncated]'; } /** * Recursively sorts associative context for stable hashing. * * @return mixed */ private function normalize( $value ) { if ( ! is_array( $value ) ) { return $value; } foreach ( $value as &$item ) { $item = $this->normalize( $item ); } unset( $item ); if ( array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) { ksort( $value ); } return $value; } }