Code draft

class-swm-analyzer.php

<?php
/**
 * Description: Attack Analyzer.
 * PHP version 8.0.1
 *
 * @category Analyzer
 * @package  SiteWatchman
 * @author   Oleg Klenitsky <klenitskiy.oleg@mail.ru>
 * @version  1.0.0
 * @license  GPLv2 or later
 */

namespace sitewatchman\swm;

if ( ! defined( 'ABSPATH' ) ) {
	exit();
}

/**
 * Класс SWM_Attack_Analyzer
 *
 * Отвечает за поведенческий и сигнатурный анализ запросов,
 * а также за автоматическую блокировку IP-адресов нарушителей.
 */
class SWM_Attack_Analyzer {
	/**
	 * Текущий целевой IP-адрес для анализа.
	 *
	 * @var string
	 */
	private $target_ip;

	/**
	 * Экземпляр класса (Singleton).
	 *
	 * @var SWM_Attack_Analyzer|null
	 */
	private static $instance = null;

	/**
	 * Максимальное количество запросов за интервал.
	 *
	 * @var int
	 */
	private $max_requests = 6;

	/**
	 * Интервал времени в секундах для проверки флуда.
	 *
	 * @var int
	 */
	private $interval_sec = 3;

	/**
	 * Сообщение при блокировке доступа.
	 *
	 * @var string
	 */
	private $block_message = 'SiteWatchman: Attack detected. Access denied.';

	/**
	 * Абсолютный путь к файлу .htaccess.
	 *
	 * @var string
	 */
	private $htaccess_path;

	/**
	 * Конструктор класса. Защищен от прямого создания экземпляра.
	 */
	private function __construct() {
		$this->htaccess_path = defined( 'ABSPATH' ) ? ABSPATH . '.htaccess' : (isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] . '/.htaccess' : './.htaccess');
	}

	/**
	 * Защита от клонирования экземпляра.
	 */
	private function __clone() {}

	/**
	 * Возвращает единственный экземпляр класса (Singleton).
	 *
	 * @return SWM_Attack_Analyzer
	 */
	public static function get_instance(): SWM_Attack_Analyzer {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Конфигурация параметров анализатора.
	 *
	 * @param  [type] $max_requests  Максимальное количество запросов.
	 * @param  [type] $interval_sec  Интервал времени в секундах.
	 * @param  [type] $block_message Сообщение при блокировке.
	 * @param  string $target_ip     IP-адрес из ядра.
	 */
	public function swm_configure( $max_requests, $interval_sec, $block_message, $target_ip = '' ) {
		$this->max_requests  = $max_requests;
		$this->interval_sec  = $interval_sec;
		$this->block_message = $block_message;
		$this->target_ip     = ! empty( $target_ip ) ? $target_ip : $this->swm_get_ip();

		if ( $this->swm_is_local( $this->target_ip ) ) {
			$this->max_requests = 0;
		}
	}

	/**
	 * Получение реального IP-адреса посетителя с валидацией.
	 *
	 * @return string
	 */
	private function swm_get_ip(): string {
		$ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' );
		foreach ( $ip_keys as $key ) {
			if ( ! empty( $_SERVER[ $key ] ) ) {
				$parts = explode( ',', $_SERVER[ $key ] );
				$ip    = trim( $parts[0] );
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}
		}
		return '0.0.0.0';
	}

	/**
	 * Проверка посетителя на флуд напрямую по записям в БД.
	 *
	 * @return bool True, если лимит запросов превышен.
	 */
	public function swm_check_flood(): bool {
		global $wpdb;
		if ( empty( $this->target_ip ) ) {
			return false;
		}

		$max_limits     = $this->max_requests > 0 ? $this->max_requests : 5;
		$interval_sec   = $this->interval_sec > 0 ? $this->interval_sec : 2;
		$time_threshold = date( 'Y-m-d H:i:s', current_time( 'timestamp' ) - $interval_sec );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$actual_hits = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors WHERE user_ip LIKE %s AND time_visit >= %s",
				'%' . $wpdb->esc_like( $this->target_ip ) . '%',
				$time_threshold
			)
		);

		return $actual_hits > $max_limits;
	}

	/**
	 * Сигнатурный анализ запроса (Anti-Exploit / WAF).
	 */
	public function swm_check_payload(): bool {
		// Использование esc_url_raw и wp_unslash гарантирует сохранность параметров запроса (?file=.env).
		$request_uri  = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		$query_string = isset( $_SERVER['QUERY_STRING'] ) ? esc_url_raw( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';

		// 1. Декодируем и нормализуем данные для глубокого анализа.
		$normalized_uri = rawurldecode( urldecode( $request_uri ) );
		$input_data     = $normalized_uri . ' ' . rawurldecode( urldecode( $query_string ) );

		if ( ! empty( $_POST ) ) {
			$safe_post   = map_deep( wp_unslash( $_POST ), 'sanitize_text_field' );
			$input_data .= ' ' . wp_json_encode( $safe_post );
		}

		// Убираем SQL-комментарии /**/ и заменяем любые пробельные символы на один пробел.
		$input_data = preg_replace( '/\/\*.*?\*\//s', '', $input_data );
		$input_data = preg_replace( '/\s+/', ' ', $input_data );

		// 2. Строгие паттерны для поиска уязвимостей (WAF Блок).
		$danger_patterns = array(
			'.env',
			'.git/config',
			'wp-config.txt',
			'wp-config.bak',
			'wp-config.old',
			'wp-json/wp/v2/users',
			'author=',
			'xmlrpc.php',
			'eval(',
			'base64_decode',
			'/wp-content/uploads/',
			'etc/passwd',
			'../',
			'UNION SELECT',
			'<script',
			'javascript:',
		);

		// Безопасно экранируем каждый паттерн под разделитель '/', чтобы не ломать регулярное выражение.
		$escaped_patterns = array_map(
			function( $pattern ) {
				return preg_quote( $pattern, '/' );
			},
			$danger_patterns
		);

		$escaped_patterns[] = 'author=\d+';

		$regex = '/' . implode( '|', $escaped_patterns ) . '/i';
		if ( preg_match( $regex, $input_data ) ) {
			return true;
		}

		// 3. Отдельная точечная проверка на опасные расширения файлов только в URI.
		if ( preg_match( '/\.(sql|bak|log|zip|rar|tar|gz)(\?|$)/i', $normalized_uri ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Автобан: Безопасная запись вредоносного IP-адреса в .htaccess.
	 *
	 * @return void
	 */
	public function swm_ban_ip(): void {
		// Используем сквозной проверенный IP из ядра вместо повторного вызова swm_get_ip().
		$ip = $this->target_ip;

		// Проверяем кэш блокировок в БД, чтобы лишний раз не терзать жесткий диск чтением файла.
		$transient_key = 'swm_banned_' . md5( $ip );
		if ( get_transient( $transient_key ) ) {
			return;
		}

		if ( ! file_exists( $this->htaccess_path ) || ! is_writable( $this->htaccess_path ) ) {
			return;
		}

		// Лочим файл на чтение/запись, предотвращая конкурентные запросы (Race Condition).
		$fp = @fopen( $this->htaccess_path, 'c+' );
		if ( ! $fp ) {
			return;
		}

		if ( flock( $fp, LOCK_EX ) ) {
			// Читаем файл только для проверки, нет ли там уже этого IP (на случай, если кэш пуст).
			$file_size = filesize( $this->htaccess_path );
			$content   = $file_size > 0 ? fread( $fp, $file_size ) : '';

			if ( false === strpos( $content, $ip ) ) {
				$start_marker = '# BEGIN SiteWatchman Bans';
				$end_marker   = '# END SiteWatchman Bans';
				$ban_rule     = 'Deny from ' . $ip . ' # Banned at ' . gmdate( 'Y-m-d H:i:s' ) . "\n";

				if ( false === strpos( $content, $start_marker ) ) {
					// Если нашего блока еще нет, безопасно дописываем его в самый конец файла.
					$new_block = "\n" . $start_marker . "\n" . $ban_rule . $end_marker . "\n";
					fseek( $fp, 0, SEEK_END );
					fwrite( $fp, $new_block );
				} else {
					// Если блок есть, нам нужно внедрить IP внутрь маркеров.
					// Чтобы не сломать файл, делаем атомарную замену только в памяти и перезаписываем.
					$replacement     = $start_marker . "\n" . $ban_rule;
					$updated_content = str_replace( $start_marker, $replacement, $content );

					ftruncate( $fp, 0 );
					fseek( $fp, 0, SEEK_SET );
					fwrite( $fp, $updated_content );
				}
			}

			flock( $fp, LOCK_UN );
		}
		fclose( $fp );

		// Запоминаем бан в кэше WordPress на 30 дней, чтобы защитить сервер от повторного fstat/fopen.
		set_transient( $transient_key, 1, 30 * DAY_IN_SECONDS );
	}

	/**
	 * Блокировка запроса с предварительным занесением в .htaccess.
	 *
	 * @return void
	 */
	public function swm_block_request() {
		// Очищаем буфер вывода, если там что-то было.
		if ( ob_get_length() ) {
			ob_end_clean();
		}

		// Устанавливаем правильный HTTP-статус Forbidden.
		status_header( 403 );
		nocache_headers();

		// Если это AJAX или REST-запрос, отдаем компактный JSON.
		if ( ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
			wp_send_json_error( array( 'message' => $this->block_message ), 403 );
		}

		// Для ботов и обычных сканеров отдаем минимальный HTML-каркас.
		if ( ! empty( $_SERVER['HTTP_X_REQUESTED_WITH'] ) || false !== stripos( isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', 'bot' ) ) {
			header( 'Content-Type: text/html; charset=utf-8' );
			echo '<h1>403 Forbidden</h1>';
			echo '<p>' . esc_html( $this->block_message ) . '</p>';
			exit();
		}

		// Для интерактивных пользователей вызываем стандартное безопасное окно WordPress.
		wp_die(
			esc_html( $this->block_message ),
			esc_html( 'Access Denied' ),
			array( 'response' => 403 )
		);
	}
}