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 )
);
}
}