Проверка на новые файлы с уведомлением в телегу

Здравствуйте.

Прочитал новый топик о новом нашествии вирусов, которые меняют файлы и что-то там «майнят» создавая нагрузку.
Решил поделиться своим простым скриптом, который контролирует появление новых файлов их изменения, и если таковые будут выявлены, вышлет уведомление в телеграм. Нужно поместить данный скрипт в папку в корне сайта.
Например, detect/file_change_detector.php

Я например, запускаю раз в 6 часов по крону.

P.S.
Пишите, если решение будет востребовано могу создать компонент для MODX

Update:
Компонент создан для MODX2 и MODX3. Загрузил в репозиторий, жду модерации.

<?php

/**
 * Скрипт для мониторинга изменений файлов в директории и отправки уведомлений в Telegram.
 * Версия для PHP 7.4+
 * 1.0.1
* Принцип работы:
 * 1. Сканирует указанную директорию рекурсивно.
 * 2. Вычисляет MD5-хеши для каждого найденного файла.
 * 3. Сравнивает текущие хеши с хешами, сохраненными при предыдущем запуске (из файла .filehashes).
 * 4. Определяет добавленные, удаленные и измененные файлы.
 * 5. Если обнаружены изменения, отправляет уведомление в Telegram.
 * 6. Сохраняет текущее состояние хешей для следующего запуска.
 * 7. Ведет лог своей работы в текстовый файл.
 */

// === НАСТРОЙКИ ===
define('MONITOR_DIR', '/home/best/web/bestkaminy.ru/public_html');
define('HASH_FILE', __DIR__ . '/.filehashes.json');
define('LOG_FILE', __DIR__ . '/file_monitor.log');
define('TELEGRAM_BOT_TOKEN', '');
define('TELEGRAM_CHAT_ID', '');
$excludePaths = [
    'core/cache/',
    'core/export/',
    'core/logs/',
    //'core/packages/',
    'assets/cache/',
    //'assets/components/',
    '.git/',
    '1detect/file_change_detector.php', // Имя вашего скрипта
    '1detect/.filehashes.json',
    '1detect/file_monitor.log',
 
];
define('MAX_FILES_IN_MESSAGE', 50);
date_default_timezone_set('Europe/Moscow');

// --- Функции ---

function logMessage(string $message): void
{
    $timestamp = date('Y-m-d H:i:s');
    $logEntry = "[{$timestamp}] {$message}" . PHP_EOL;
    file_put_contents(LOG_FILE, $logEntry, FILE_APPEND);
}

function sanitizeForTelegramCodeSpan(string $text): string
{
    $text = str_replace('`', "'", $text);
    return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}


function sendTelegramNotification(string $message): bool
{
    if (TELEGRAM_BOT_TOKEN === 'YOUR_TELEGRAM_BOT_TOKEN' || TELEGRAM_CHAT_ID === 'YOUR_TELEGRAM_CHAT_ID') {


        logMessage("Ошибка: Не настроены TELEGRAM_BOT_TOKEN или TELEGRAM_CHAT_ID в коде.");
        return false;
    }

    $url = 'https://api.telegram.org/bot' . TELEGRAM_BOT_TOKEN . '/sendMessage';
    $data = [
        'chat_id' => TELEGRAM_CHAT_ID,
        'text' => $message,
        'parse_mode' => 'Markdown',
        'disable_web_page_preview' => true,
    ];

    $options = [
        'http' => [
            'header' => "Content-type: application/x-www-form-urlencoded\r\n",
            'method' => 'POST',
            'content' => http_build_query($data),
            'ignore_errors' => true,
            'timeout' => 10,
        ],
        'ssl' => [
            'verify_peer' => false,
            'verify_peer_name' => false,
        ],

    ];

    $context = stream_context_create($options);
    $result = @file_get_contents($url, false, $context);

    if ($result === false) {
        $error = error_get_last();
        logMessage("Ошибка отправки в Telegram: Не удалось выполнить запрос. " . ($error['message'] ?? 'Нет деталей'));
        return false;
    }

    $response = json_decode($result, true);

    if (!$response || !$response['ok']) {
        $errorCode = $response['error_code'] ?? 'N/A';
        $description = $response['description'] ?? 'No description';
        $problematicPart = mb_substr($message, 0, 200);
        logMessage("Ошибка отправки в Telegram: API вернуло ошибку {$errorCode} - {$description}. Начало сообщения: {$problematicPart}");
        return false;
    }
    return true;
}


function getFilesInDirectory(string $dir, array $excludePaths, string $baseDir): array
{
    $files = [];
    $baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
    $dir = rtrim($dir, DIRECTORY_SEPARATOR);

    if (!is_dir($dir)) {
        logMessage("Ошибка: Директория '{$dir}' не найдена или не является директорией.");
        return [];
    }

    try {
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS),
            RecursiveIteratorIterator::SELF_FIRST
        );
        $iterator->setMaxDepth(-1);

        foreach ($iterator as $fileInfo) {
            $realPath = $fileInfo->getRealPath();
            if ($realPath === false) {
                logMessage("Предупреждение: Не удалось получить реальный путь для: " . $fileInfo->getPathname());
                continue;
            }
            $relativePath = str_replace($baseDir, '', $realPath);
            $normalizedRelativePath = str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
            $isExcluded = false;
            foreach ($excludePaths as $exclude) {
                if (strpos($normalizedRelativePath, rtrim($exclude, '/')) === 0) {
                    if (substr($exclude, -1) === '/') {
                        if (strpos($normalizedRelativePath . '/', $exclude) === 0) {
                            $isExcluded = true;
                            break;
                        }
                    } else {
                        if ($normalizedRelativePath === $exclude) {
                            $isExcluded = true;
                            break;
                        }
                    }
                }
            }
            if ($isExcluded) continue;

            if ($fileInfo->isFile() && $fileInfo->isReadable()) {
                $hash = @md5_file($realPath);
                if ($hash === false) {
                    logMessage("Предупреждение: Не удалось вычислить хеш для файла: {$relativePath}");
                    continue;
                }
                $files[$relativePath] = $hash;
            }
        }
    } catch (Exception $e) {
        logMessage("Ошибка при сканировании директории '{$dir}': " . $e->getMessage());
    }
    return $files;
}

function loadHashes(string $filepath): array
{
    if (!file_exists($filepath) || !is_readable($filepath)) return [];
    $content = @file_get_contents($filepath);
    if ($content === false) {
        logMessage("Ошибка: Не удалось прочитать файл хешей '{$filepath}'.");
        return [];
    }
    $hashes = json_decode($content, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
        logMessage("Ошибка: Не удалось декодировать JSON из файла хешей '{$filepath}'. Ошибка: " . json_last_error_msg());
        if (!empty(trim($content))) {
            $backupFile = $filepath . '.bak.' . date('YmdHis');
            @copy($filepath, $backupFile);
            logMessage("Создана резервная копия поврежденного файла хешей: {$backupFile}");
        }
        return [];
    }
    return is_array($hashes) ? $hashes : [];
}

function saveHashes(string $filepath, array $hashes): bool
{
    $jsonContent = json_encode($hashes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    if (json_last_error() !== JSON_ERROR_NONE) {
        logMessage("Ошибка: Не удалось закодировать хеши в JSON. Ошибка: " . json_last_error_msg());
        return false;
    }
    if (@file_put_contents($filepath, $jsonContent) === false) {
        logMessage("Ошибка: Не удалось записать хеши в файл '{$filepath}'. Проверьте права на запись.");
        return false;
    }
    return true;
}

// --- Основная логика ---

logMessage("Запуск скрипта мониторинга.");

if (!is_dir(MONITOR_DIR)) {
    $errorMsg = "Критическая ошибка: Директория для мониторинга '" . MONITOR_DIR . "' не существует или недоступна.";
    logMessage($errorMsg);
    sendTelegramNotification("⚠️ Ошибка конфигурации мониторинга файлов:\nДиректория `" . sanitizeForTelegramCodeSpan(MONITOR_DIR) . "` не найдена.");
    exit(1);
}

$oldHashes = loadHashes(HASH_FILE);
$isFirstRun = empty($oldHashes);

if ($isFirstRun) {
    logMessage("Файл хешей '" . HASH_FILE . "' не найден или пуст. Считаем это первым запуском.");
} else {
    logMessage("Загружено " . count($oldHashes) . " хешей из файла '" . HASH_FILE . "'.");
}

logMessage("Начинаю сканирование директории '" . MONITOR_DIR . "'.");
$currentHashes = getFilesInDirectory(MONITOR_DIR, $excludePaths, MONITOR_DIR);
logMessage("Сканирование завершено. Найдено " . count($currentHashes) . " файлов для проверки.");

$addedFiles = [];
$deletedFiles = [];
$modifiedFiles = [];

foreach ($currentHashes as $file => $hash) {
    if (!isset($oldHashes[$file])) {
        $addedFiles[] = $file;
    } elseif ($oldHashes[$file] !== $hash) {
        $modifiedFiles[] = $file;
    }
}
foreach ($oldHashes as $file => $hash) {
    if (!isset($currentHashes[$file])) {
        $deletedFiles[] = $file;
    }
}

$totalChanges = count($addedFiles) + count($deletedFiles) + count($modifiedFiles);

if ($totalChanges > 0) {
    logMessage("Обнаружены изменения: Добавлено: " . count($addedFiles) . ", Удалено: " . count($deletedFiles) . ", Изменено: " . count($modifiedFiles));

    if ($isFirstRun) {
        logMessage("Первый запуск: инициализация списка файлов. Уведомление в Telegram не отправлено.");
    } else {
        $message = "⚠️ *Обнаружены изменения в файлах сайта!*\n\n";
        $message .= "Проверена директория: `" . sanitizeForTelegramCodeSpan(MONITOR_DIR) . "`\n";
        $message .= "Время проверки: " . date('Y-m-d H:i:s') . "\n\n";

        $filesListed = 0;
        if (!empty($modifiedFiles)) {
            $message .= "*Измененные файлы (" . count($modifiedFiles) . " шт.):*\n";
            foreach ($modifiedFiles as $file) {
                if ($filesListed >= MAX_FILES_IN_MESSAGE) {
                    $message .= "- ...и еще " . (count($modifiedFiles) - $filesListed) . " файлов\n";
                    break;
                }
                $message .= "- `" . sanitizeForTelegramCodeSpan($file) . "`\n";
                $filesListed++;
            }
            $message .= "\n";
        }

        $filesListed = 0;
        if (!empty($addedFiles)) {
            $message .= "*Добавленные файлы (" . count($addedFiles) . " шт.):*\n";
            foreach ($addedFiles as $file) {
                if ($filesListed >= MAX_FILES_IN_MESSAGE) {
                    $message .= "- ...и еще " . (count($addedFiles) - $filesListed) . " файлов\n";
                    break;
                }
                $message .= "- `" . sanitizeForTelegramCodeSpan($file) . "`\n";
                $filesListed++;
            }
            $message .= "\n";
        }

        $filesListed = 0;
        if (!empty($deletedFiles)) {
            $message .= "*Удаленные файлы (" . count($deletedFiles) . " шт.):*\n";
            foreach ($deletedFiles as $file) {
                if ($filesListed >= MAX_FILES_IN_MESSAGE) {
                    $message .= "- ...и еще " . (count($deletedFiles) - $filesListed) . " файлов\n";
                    break;
                }
                $message .= "- `" . sanitizeForTelegramCodeSpan($file) . "`\n";
                $filesListed++;
            }
            $message .= "\n";
        }

        $maxLen = 4000;
        if (mb_strlen($message, 'UTF-8') > $maxLen) {
            logMessage("Сообщение слишком длинное (" . mb_strlen($message, 'UTF-8') . " символов), будет разбито на части.");
            $partsSentSuccessfully = 0;
            $lines = explode("\n", $message);
            $currentPart = "";
            foreach ($lines as $line) {
                if (mb_strlen($currentPart . $line . "\n", 'UTF-8') > $maxLen) {
                    if (!empty(trim($currentPart))) {
                        if (sendTelegramNotification(trim($currentPart))) {
                            $partsSentSuccessfully++;
                        } else {
                            logMessage("Не удалось отправить часть уведомления в Telegram (после разбивки по строкам).");
                        }
                        usleep(500000);
                    }
                    $currentPart = $line . "\n";
                } else {
                    $currentPart .= $line . "\n";
                }
            }
            if (!empty(trim($currentPart))) {
                if (sendTelegramNotification(trim($currentPart))) {
                    $partsSentSuccessfully++;
                } else {
                    logMessage("Не удалось отправить последнюю часть уведомления в Telegram.");
                }
            }
            if($partsSentSuccessfully > 0) logMessage("Уведомление успешно отправлено в Telegram ($partsSentSuccessfully частей).");

        } else {
            if (sendTelegramNotification($message)) {
                logMessage("Уведомление успешно отправлено в Telegram (целиком).");
            } else {
                logMessage("Не удалось отправить уведомление в Telegram (целиком).");
            }
        }
    }

    logMessage("Сохраняю актуальное состояние хешей в файл '" . HASH_FILE . "'.");
    if (!saveHashes(HASH_FILE, $currentHashes)) {
        logMessage("Критическая ошибка: Не удалось сохранить файл хешей. Изменения при следующем запуске могут быть определены некорректно.");
        sendTelegramNotification("⚠️ Ошибка мониторинга файлов:\nНе удалось сохранить файл хешей `" . sanitizeForTelegramCodeSpan(HASH_FILE) . "`. Проверьте права на запись.");
    }

} else {
    logMessage("Изменений в файлах не обнаружено.");
    if ($oldHashes !== $currentHashes) {
        logMessage("Сохраняю актуальное состояние хешей (несмотря на отсутствие изменений, предыдущее сохранение могло быть неполным или файл хешей был поврежден).");
        if (!saveHashes(HASH_FILE, $currentHashes)) {
            logMessage("Критическая ошибка: Не удалось сохранить файл хешей при отсутствии изменений. Проверьте права на запись для файла: " . HASH_FILE);
        }
    }
}

logMessage("Завершение работы скрипта.");
exit(0);
Ivan K.
15 мая 2025, 13:43
modx.pro
3
781
+9

Комментарии: 9

Алексей
15 мая 2025, 13:54
+1
Хорошее решения для того что бы не отслеживать сайт самому, да ещё и уведомляет в телеграм, интересно на сколько сильно загружает во время обработки, а так супер, если будет приложение, я думаю будет пользоваться спросом
    Дима Касаткин
    15 мая 2025, 15:03
    +1
    Круто! Считай готовый «антивирус». Конечно голосуем за пакет! (и поддержку sсheduler для периодического запуска :))
      Ivan K.
      17 мая 2025, 21:26
      +1
      А вы какой планировщик имеете ввиду? (Scheduler — modstore.pro/packages/utilities/scheduler) он?
      Но там ведь нет возможности указать периодичность запусков заданий. Только можно указать точное время запуска.
      При такой структуре Scheduler'а, для достижения периодичности выполнения нужно будет реализовать логику самоперепланирования внутри скриптов, а первый запуск придется сделать вручную. Это как-то не очень удобно не находите? и не очень надежно. Если вы знаете в каком компоненте есть реализация нормальной работы с этим планировщиком напишите. я гляну, вдруг что-то не так понимаю.
      Василий Наумкин
      21 мая 2025, 04:00
      +3
      Насколько я вижу из кода, скрипт в первый раз сохраняет хэши файлов, а потом их проверяет. Но если сайт уже заражён — то это никак не поможет.

      Подкидываю альтернативную идею, если интересно — проверять версию MODX (или брать из настроек), скачивать соответствующий дистрибутив, и проверять хэши файлов сайта по файлам дистрибутива.

      То есть, берём оригинальные файлы index.php в connectors, manager и корне, а так же файлы из core — и проверяем, чтобы все они присутствовали на сайте с оригинальным хэшем.

      Если все основные файлы не изменены, то сайт не заражён и должен работать корректно.

      Правда, есть еще возможность заражения только файлов дополнений, без ядра. Наверное, можно и их сверять с дистрибутивами из репозитория по той же логике — скачать нужную версию и сравнить хэши…

      Кстати, вот вам еще идея — создать онлайн базу для проверки хэшей файлов MODX и дополнений через API. Чтобы простые GET запросы, типа /api/hash/modx/2.8.1/core/model/modx.class.php возвращали sha1 хэш запрошенного файла или 404.

      Конечно, это не спасёт от уже залитых шеллов и вредоносов, но они не будут запускаться через сайт. А если запустятся и что-то изменят, то следующая проверка это покажет. И если раз за разом файлы будут меняться — то можно уже более внимательно искать, что там такое у вас залито.
        Артур Шевченко
        21 мая 2025, 10:18
        0
        создать онлайн базу для проверки хэшей файлов MODX и дополнений через API
        Интересная идея, я бы занялся на досуге. Ты мог бы в общих чертах описать для каких файлов делать хэш у ядра и у компонентов?
          Василий Наумкин
          21 мая 2025, 14:35
          0
          Да для всех, какие есть в дистрибутиве — лишнего-то там быть ничего не должно, по идее.

          В идеале вообще автоматизировать и качать всё новое бесплатное из репозитория MODX / modstore, распаковывать и забивать в БД:
          — название: MODX или дополнение
          — версия дистрибутива
          — путь к файлу
          — sha1 хэш файла
            Артур Шевченко
            21 мая 2025, 14:45
            0
            А файлы ядра тоже все хэшировать?
              Василий Наумкин
              22 мая 2025, 18:00
              +2
              Да, конечно. Разве что кроме архива core.transport.zip.

              Смысл же в том, чтобы сравнить свои файлы с эталонными на предмет изменений. Файлы ядра меняться не должны.
        Ivan K.
        11 июня 2025, 17:29
        +1
        Компонент создан для MODX2 и MODX3. Загрузил в репозиторий, жду модерации.
          Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
          9