Кто выгрузил платежи, или Пример расследования инцидента на аудите в Postgres Pro Enterprise

от автора

Сможете ли вы прямо сейчас, за пять минут, ответить ИБ на вопрос «кто читал таблицу платежей, с какого адреса и сколько строк выгрузил»? Если ваш PostgreSQL не подготовлен заранее — нет. И дело не в лени админа, а в том, что штатного аудита в PostgreSQL просто не существует. Разберём, почему так, и как эту дыру закрывает Postgres Pro Enterprise.

Завязка, или Инсайдер выгрузил платежи

Пятница, вечер. В общий чат прилетает сообщение:

кхе-кхе, в общем мониторинге только что заметил аномальный скачок чтения на таблице платежей… Давайте проверим через аудит, нет ли там утечки. Надо понять, кто, с какого адреса и сколько точно строк было прочитано.

Если у вас ванильный и заранее не подготовленный PostgreSQL, то на этот вопрос вы, скорее всего, не ответите. И вот почему.

Боль аудита в PostgreSQL

В чистом PostgreSQL аудита как отдельной подсистемы нет. Но есть отдельные кусочки, из которых можно собрать подобие аудита:

  • log_statement = all — включает запись всех запросов в общий лог СУБД. Но это не аудит, а свалка — вперемешку с запросами приложения, без понятия «событие безопасности», без структуры, без признака «успех/отказ». Найти в этом потоке один SELECT к конкретной таблице, сделанный конкретной ролью с конкретного IP, — это отдельный квест с grep.

  • log_line_prefix — управляет форматом префикса каждой строки лога: сюда можно добавить, например, IP клиента (%h), имя роли, базы, PID и много чего ещё. Но это лишь префикс к тем же текстовым строкам, и по умолчанию многие вещи там отключены.

  • pgaudit — заметно улучшает ситуацию: умеет и сессионный аудит по классам команд (READ/WRITE/DDL/ROLE/…), и пообъектный аудит отдельной таблицы (через роль и GRANT), и даже число строк (pgaudit.log_rows). Но есть проблема в форме: аудит-события он пишет в общий серверный лог вперемешку с остальным и нужные для расследования поля приходится собирать из разных мест, например адрес клиента только через log_line_prefix, число строк и прочее — добавками к текстовой строке, которую затем надо парсить. Более того, отсутствует единый формат «одно событие — все поля», нет аудита аутентификации/отключений, и отказ доступа (permission denied) отдельным аудит-сигналом не выделяется. Ах да и вишенка на торте: It is not possible to reliably audit superusers with pgAudit (с).

  • Триггеры на таблицы — увы, закрывают только DML и только на тех таблицах, где их заранее повесили. SELECT-запросы триггером не поймать в принципе (а чтение это главный канал утечки).

Что в итоге? На вопрос к ИБ «давайте проверим через аудит, нет ли там утечки» со штатными средствами PostgreSQL по умолчанию ответить невозможно, а с теми инструментами, что есть, ответить очень сложно. Чтения не логируются прицельно, большая часть полезной информации по умолчанию недоступна или разбросана по тексту серверного лога, без единой структуры событий.

В Postgres Pro Enterprise это решается встроенным расширением pg_proaudit.

О pg_proaudit и pgpro-otel-collector

pg_proaudit — это встроенная в Postgres Pro Enterprise подсистема аудита: декларативные правила (что и для кого фиксировать), структурированный CSV-журнал с готовыми полями (роль, IP клиента, тип события, объект, статус, текст запроса, число затронутых строк), прицельные политики по объектам/ролям и аудит доступа к системному каталогу. Подробности — в документации pg_proaudit и в обзорной статье на Хабре: «pg_proaudit: аудит в Postgres Pro Enterprise».

pgpro-otel-collector — это сборка OpenTelemetry Collector от Postgres Pro, заточенная под более детальную наблюдаемость Postgres. У otel-коллектора репутация сборщика метрик: статистика СУБД, репликация, активность бэкендов — всё это верно, но составляет лишь часть картины.

OpenTelemetry Collector по своей архитектуре — это конвейер для трёх типов сигналов: метрики, логи и трейсы. Один бинарник, что собирает метрики, умеет собирать и логи: стандартный приёмник file_log читает файлы, операторы разбирают строки в структуру, а экспортёр otlp_http (или любой другой) отправляет результат в хранилище. То есть коллектор — это ещё и полноценный сборщик логов, а не только метрик.

Но просто логи СУБД это скучно: перекладывать строки из файла в хранилище умеет десяток агентов. Интереснее, взглянуть на лог как на структурированный поток событий безопасности: кто, что, над каким объектом, с какого адреса, успех или отказ, сколько строк. Ровно такой поток даёт pg_proaudit, и именно на стыке «аудит Enterprise + сбор коллектором» появляется та ценность, ради которой я ввязался в эксперимент (да ещё и в выходной!).

Дальше по тексту фиксируем, что otel-коллектор работает именно как лог-конвейер: читает CSV-журнал аудита, разбирает его в 20 полей и складывает в хранилище с быстрым поиском.

Архитектура сбора

Журнал аудита сам по себе — это CSV-файлы на диске сервера. Чтобы расследовать инциденты, их нужно собрать и положить в хранилище с быстрым поиском. Наш конвейер будет состоять из следующих компонентов:

  • pg_proaudit пишет события в выделенный каталог, отдельный от PGDATA, — /var/log/pg_proaudit/, файлы — pg_proaudit-*.log.

  • pgpro-otel-collector читает их приёмником file_log, разбирает CSV в 20 структурированных полей, навешивает resource-атрибуты и экспортирует в VictoriaLogs по OTLP HTTP.

  • Victorialogs является хранилищем, в нем мы и ведём расследование на языке LogsQL.

  • Grafana компонент опциональный, показывает агрегированную картину в виде дашборда.

Дальше чуть подробнее о том, как собирается этот конвейер.

Пошаговая настройка

Идет последовательно: сначала настраивается PostgreSQL — параметры и правила аудита, затем otel-коллектор — сбор и отправка аудит-журналов. Настройку VictoriaLogs и Grafana пропускаем, чтобы не раздувать объём статьи.

1. GUC-параметры

pg_proaudit подгружается через shared_preload_libraries, журнал пишется в формате CSV и нужные нам поля включаются параметрами сервера:

# формат вывода аудита pg_proaudit: CSV (есть также csvlog/ceflog/syslog/cefsyslog). Сам 20-польный аудит-CSV — собственный формат pg_proaudit, а не PostgreSQL-csvlog.pg_proaudit.log_destination = 'csvlog'# каталог хранения журнала аудитаpg_proaudit.log_directory = '/var/log/pg_proaudit'# шаблон файлов журнала аудита pg_proaudit.log_filename = 'pg_proaudit-%Y-%m-%d_%H%M%S.log'# писать текст команды в поле query_textpg_proaudit.log_command_text = on# писать число затронутых строк в поле rows — ключевое поле для детекта эксфильтрацииpg_proaudit.log_rows = on# аудировать доступ к системному каталогуpg_proaudit.log_catalog_access = on# права на файлы журнала: владелец+группа на чтение, миру — запрещеноpg_proaudit.log_file_mode = 0640

log_rows = on — это то, чего нет в штатном PostgreSQL и что напрямую отвечает на вопрос, сколько строк выгрузили. По log_file_mode = 0640 пролегает граница доступа: к журналу должен иметь доступ пользователь, от имени которого запущен otel-коллектор. И тут есть варианты, можно запускать от имени postgres либо запускать отдельным пользователем, добавив его в группу postgres. В моём случае я выбрал вариант с отдельным пользователем и группой, отсюда и права 0640.

2. Правила аудита

Расширение создаётся явно (библиотека предзагружена, но extension нужно установить), правила задаются функцией с фиксированной сигнатурой:

pg_proaudit_set_rule(db_name, event_type, object_type, object_name, role_name [, comment])

Политика стенда выбрана выборочная, так как логировать всё подряд дорого (поднимите руку те, кто любит capacity planning с горизонтом в 2-5 лет) — и по нагрузке на сервер, и по объёму журнала:

CREATE EXTENSION IF NOT EXISTS pg_proaudit;-- DML только на таблице платежей (ВАЖНО: object_name ОБЯЗАН быть schema-qualified)SELECT pg_proaudit_set_rule(current_database(), 'ALL_DML', 'TABLE', 'public.payments', NULL);-- глобально: невременные DDL, операции с ролями, аутентификация и отключенияSELECT pg_proaudit_set_rule(current_database(), 'ALL_DDL_NONTEMP', NULL, NULL, NULL);SELECT pg_proaudit_set_rule(current_database(), 'ALL_ROLE',        NULL, NULL, NULL);SELECT pg_proaudit_set_rule(current_database(), 'AUTHENTICATE',    NULL, NULL, NULL);SELECT pg_proaudit_set_rule(current_database(), 'DISCONNECT',      NULL, NULL, NULL);-- доступ к каталогу аудируется только когда DML-правило матчит объект каталога.-- Правило, привязанное к конкретной роли insider, делает её обращения к pg_catalog видимыми,-- НЕ зашумляя журнал служебными чтениями каталога легитимными ролями.SELECT pg_proaudit_set_rule(current_database(), 'ALL_DML', NULL, NULL, 'insider');-- зафиксировать и применить правилаSELECT pg_proaudit_save();SELECT pg_proaudit_reload();

Второй раз подсвечу момент, когда можно споткнуться: object_name должен быть schema-qualified. Правило на payments (без схемы) сохранится, но никогда не сматчится — событий DML не будет. Только public.payments.

3. Конфиг коллектора

Коллектор читает журналы ресивером file_log, парсит 20 полей и шлёт в VictoriaLogs через экспортёр otlp_http:

receivers:  file_log:    # маска (не фиксированное имя) переживает ротацию журналов pg_proaudit    include: [ /var/log/pg_proaudit/*.log ]    # читать с начала — события, записанные до старта коллектора, не теряются    start_at: beginning    multiline:      line_start_pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}    operators:      - type: csv_parser        # 20 полей pg_proaudit CSV в точном порядке (имена — по документации),        # завершается полем user_ip        header: log_time,role_name,database_name,pid,severity,command_line_num,subcommand_line_num,event_type,object_type,object_name,status,error_message,query_text,query_args,session_user,uuid,xid,vxid,rows,user_ip        timestamp:          parse_from: attributes.log_time          layout_type: strptime          layout: '%Y-%m-%d %H:%M:%S.%L %Z'        # полный severity-маппинг (не только INFO/ERROR — иначе теряется часть сигнала)        severity:          parse_from: attributes.severity          mapping:            debug: [ DEBUG ]            info:  [ INFO, NOTICE, LOG ]            warn:  [ WARNING ]            error: [ ERROR ]            fatal: [ FATAL, PANIC ]      - type: remove        id: remove_log_time        field: attributes.log_time      - type: remove        id: remove_severity        field: attributes.severityprocessors:  resource:    attributes:      - key: service.name        action: upsert        value: pg_proaudit      - key: service.instance.id        action: upsert        value: postgres:5432exporters:  otlp_http:    endpoint: http://victorialogs:9428/insert/opentelemetry    encoding: proto    compression: gzip    retry_on_failure:      enabled: true      initial_interval: 1s      max_interval: 30s      max_elapsed_time: 5mservice:  pipelines:    logs:      receivers:  [ file_log ]      processors: [ resource ]      exporters:  [ otlp_http ]

Ровно 20 полей CSV, заканчивающихся на user_ip — это и есть набор, по которому я буду расследовать. Ключевые для нас: role_nameuser_ipevent_typeobject_typeobject_namestatusquery_textrows.

Отмечу, что пример конфига с интеграцией pg_proaudit есть в пакете pgpro-otel-collector.

Пять сценариев — расследование через LogsQL

А вот и главное. Учимся спрашивать журнал на LogsQL, потому что именно запрос отвечает на конкретный вопрос ИБ. На стенде заранее настроена непрерывная рабочая нагрузка по пяти сценариям безопасности плюс легитимный фон от app_user/analyst. Наш инсайдер (insider) действует с адреса user_ip 172.24.0.5, с того же адреса откуда идёт и фоновая активность приложения и аналитика. Возможно аналитик навайбкодил карманный сервис и не озаботился вопросами безопасности. Сначала соберём фактуру, потом пойдём с вопросами к аналитику.

Запросы выполняются в VictoriaLogs (UI на http://127.0.0.1:9428 или через /select/logsql/query).

Сценарий 1 — Data Access: кто читал платежи

Возвращаемся к исходному вопросу. Все обращения к таблице платежей:

object_name:payments

Это word-match-поиск LogsQL и он находит public.payments. В выдаче — события с полями реального события аудита:

role_name

insider

user_ip

172.24.0.5

event_type

SELECT

object_name

public.payments

rows

5000

status

SUCCESS

query_text

SELECT * FROM payments

Вот и ответ ИБ за один запрос: легитимные app_user/analyst читают payments маленькими порциями (count(*)LIMIT 50), а insider сделал полный скан без LIMIT на 5000 строк. Большое значение rows — это и есть сигнатура эксфильтрации, которая и привлекла внимание админа в общем мониторинге.

Оговорка на будущее: приём «большое rows» сработал, потому что у легитимного доступа здесь есть понятный профиль — мелкие порции (count(*)LIMIT), на фоне которых выброс сразу заметен. В проде такой профиль (baseline) заранее не задан, его строят из самого же журнала аудита: за репрезентативный период агрегируют привычные объёмы чтения по ролям и адресам (например object_name:payments | stats by (role_name, user_ip) max(rows), count()) и принимают их за норму. Тогда аномалия — это чтение заметно выше привычного объёма или с нового адреса, или нехарактерного юзера, а не «любой SELECT на 5000 строк».

Уточняем картину утечки агрегатом «кто и сколько»:

object_name:payments AND event_type:SELECT | stats by (role_name, user_ip) max(rows), sum(rows)

Ответ на вопрос руководства, кто (role_name=insider), с какого адреса (user_ip), сколько строк (rows=5000), получен. Дальше расширяем расследование на смежные группы событий.

Сценарий 2 — Access Control: эскалация привилегий

Все операции с ролями и правами (создание/изменение ролей, выдача членства, GRANT):

event_type:~"(?i)role|grant"

Здесь видно, кто кому выдавал права. В стенде dba создаёт временные роли, выдаёт членство, меняет атрибуты — всё это фиксируется правилом ALL_ROLE. Отдельно ловится попытка выдать SUPERUSERdba не суперпользователь, попытка отклоняется, и в журнале остаётся событие со status=FAILURE — классический сигнал попытки эскалации.

Сценарий 3 — Schema Changes: деструктивный DDL

Изменения схемы — создание, изменение, удаление, очистка объектов:

event_type:~"(?i)create|alter|drop"

Правило ALL_DDL_NONTEMP фиксирует DDL (кроме временных объектов). Видно, какие объекты и кем изменены/удалены: DROP TABLE, ALTER TABLE и т.п. с указанием role_name и object_name.

Сценарий 4 — Sessions & Auth: аутентификация и подбор пароля

Входы и отключения, в том числе неудачные:

event_type:~"(?i)authenticate|disconnect"

Правила AUTHENTICATE/DISCONNECT дают события входа/выхода. Стенд имитирует подбор пароля: несколько неудачных подключений insider с неверным паролем дают серию событий со status=FAILURE. Всплеск неудачных входов — повод присмотреться.

Сценарий 5 — Catalog Recon: разведка по системному каталогу

Доступ к системным каталогам — кто «шарил» список таблиц, ролей, функций:

object_type:~"(?i)catalog"

Реальное значение поля — object_type='CATALOG RELATION'. Благодаря pg_proaudit.log_catalog_access = on и role-scoped правилу на insider его чтения pg_class/pg_attribute/pg_namespace/pg_proc/pg_roles/pg_database всплывают как обращения к каталогу. Отдельно — запрещённая проба pg_authid (хэши паролей): доступ отклонён, но событие со status=FAILURE всё равно записано. Это и есть «разведка перед атакой», тот же insider.

Сшиваем расследование

Теперь, зная виновника по сценарию 1, поднимаем весь его след одним фильтром по user_ip и именем insider:

user_ip:"172.24.0.5" AND role_name:"insider" | stats by (event_type) count()

Вся активность инсайдера с одного адреса — разведка по каталогу, неудачные входы и финальная выгрузка платежей — собирается в единую картину инцидента. Расследование закрыто: кто (insider), откуда (user_ip), сколько (rows=5000) и что ещё он делал.

Grafana-дашборд — вид сверху

LogsQL хорош для точечных вопросов расследования, когда ты уже знаешь, что искать. Но чтобы вопрос вообще возник, аномалию надо сначала заметить. Для этого поверх того же потока событий собран дашборд в Grafana: он даёт «вид сверху» и работает как первый экран дежурного — место, где отклонение бросается в глаза раньше, чем кто-то прибежит с вопросом в чат ИБ.

Наверху — ряд ключевых показателей. Осевой из них — доля status=FAILURE: одно число, по которому сразу понятно, «спокойно» сейчас или «горит». Рядом стоит его расшифровка: общее число событий и отдельно число отказов, чтобы видеть не только процент, но и абсолют за ним. Дальше — Max rows read, максимум строк, прочитанных одним запросом: прямой индикатор эксфильтрации, который не спрячешь за средними. И пара «уникальных» счётчиков: сколько различных ролей и адресов засветилось за период. У большинства плиток под числом нарисован спарклайн — маленький график тренда, чтобы с ходу отличить «так было всегда» от «только что скакнуло».

Ниже — обзорные графики: распределение событий по типам во времени, разбивка потока на успехи, отказы и отдельно — пики rows (те самые всплески чтения). Все они столбчатые, с агрегацией по 5-минутным окнам: так виден общий ритм нагрузки, а не мельтешение отдельных событий.

Ещё ниже дашборд повторяет уже знакомые пять групп: Data Access, Access Control, Schema Changes, Sessions & Auth, Catalog Recon. В каждой рядом стоят таблица последних событий и график их динамики. Это уровень drill-down: заметил аномалию наверху — спускаешься в нужную секцию за деталями, а оттуда уже уходишь в LogsQL за точным ответом. Логика простая: дашборд помогает заметить, LogsQL — разобраться.

В секции Data Access отдельной колонкой выведен rows, и крупные чтения визуально выделяются на фоне мелких легитимных — та самая аномалия относительно нашего baseline, с которой всё и началось.

Что дальше

Мы прошли путь от вопроса «кто выгрузил платежи?» до точного ответа, кто (insider), с какого user_ip и сколько строк (rows=5000) — на встроенном аудите Postgres Pro Enterprise, без единого триггера и без grep по серверному логу. Нам помогли прицельные правила pg_proaudit, структурированный CSV с полями role_name/user_ip/rows/status, сбор коллектором pgpro-otel-collector в VictoriaLogs и расследование на LogsQL.

Если у вас в проде PostgreSQL и на вопросы ИБ, кто прочитал и сколько строк выгрузил, вы сегодня отвечать не можете, — у Enterprise это уже встроено. Возьмите триал и проверьте на своём контуре.

ссылка на оригинал статьи https://habr.com/ru/articles/1057898/