Представьте каталог /opt/integrations/ и в нём файл .env. Внутри десяток строк: SHOP_A_MARKETPLACE_TOKEN, SHOP_A_CRM_KEY, SHOP_B_MARKETPLACE_TOKEN, SHOP_B_CRM_KEY, пароль почтового сервиса. У каждого магазина «свой» токен, так что формально всё разведено по клиентам. Но читать этот файл может любой процесс, запущенный от того же пользователя: сборщик заказов магазина A, выгрузчик остатков магазина B, скрипт отчётов и даже вспомогательная утилита, которую поставили на прошлой неделе. Изоляция получилась бумажная: названия переменных разные, а круг читателей один.

Вопрос, ради которого стоит разбираться, звучит узко: как сделать так, чтобы секреты конкретного магазина были видны только тем процессам, которым они нужны для работы. Не «как сделать всё безопасно», а именно как очертить круг. Дальше разберём это на примере двух магазинов и трёх сервисов, а потом посмотрим, где значения всё равно могут просочиться, и проведём замену токена у одного магазина, не трогая второй.

Сначала карта: кому что нужно на самом деле

Прежде чем раскладывать файлы, полезно выписать, какие секреты вообще существуют и кто их использует. Возьмём условный пример, он нужен только для разбора. Есть два магазина: «Север» и «Юг». Работают три сервиса:

  • orders-sync забирает заказы с торговой площадки и передаёт их в CRM;
  • stock-push отправляет остатки на площадку;
  • notifier шлёт сообщения менеджерам о новых заказах.

Теперь для каждой пары «сервис — магазин» спросим, какие секреты ему действительно необходимы.

Карта доступа: два магазина, три сервиса
Секретorders-syncstock-pushnotifier
Токен площадки, «Север»читаетчитаетнет
Токен площадки, «Юг»читаетчитаетнет
Ключ CRM, «Север»читаетнетнет
Ключ CRM, «Юг»читаетнетнет
Токен бота уведомленийнетнетчитает

Уже здесь видно, что общий файл раздаёт лишнее. Сервису остатков не нужен ключ CRM, а у рассыльщика нет причин видеть токены площадки. Принцип минимальных прав для отдельных секретов, который описывает памятка OWASP по управлению секретами, на практике начинается именно с такой таблицы: сначала выясняем, кто и что читает, потом строим доступ.

Есть и второе измерение, которое таблица скрывает. В нашем примере orders-sync обслуживает оба магазина одним процессом. Это удобно, но означает, что компрометация этого процесса открывает токены обоих. Если для вас важна граница между магазинами, а не только между сервисами, то процесс придётся разделить: экземпляр для «Севера» и экземпляр для «Юга». Тогда в строке «orders-sync» появятся две строки, и каждая увидит лишь свой набор. Цена очевидна: вдвое больше процессов и конфигураций. Выбор зависит от того, чьи интересы вы разделяете. Если оба магазина ваши и сидят в одной команде, достаточно границы между сервисами. Если это разные арендаторы со своими договорённостями, граница между магазинами обычно нужна.

Минимальный набор секретов интеграции

Отдельно стоит проверить, нет ли в наборе лишнего. Токен площадки часто выдают с широкими правами: чтение заказов, запись остатков, управление карточками. Если сервис только читает заказы, а площадка позволяет выпустить токен с узкой областью действия, лучше выпустить такой. Тогда утечка из orders-sync не даст менять остатки. Если площадка так не умеет, это ограничение конкретной интеграции, и его нужно учесть в карте: сервис, которому достался широкий токен, считается обладателем всех его возможностей, а не только тех, что вызывает код.

Секрет и ссылка на секрет — разные вещи

Вторая ошибка, которая встречается рядом с первой: конфигурация задачи содержит сам токен. Допустим, у задачи синхронизации есть настройки в YAML или в таблице базы: расписание, адрес API, идентификатор магазина, и тут же токен. Такой конфиг попадает в репозиторий, в панель администратора, в экспорт для поддержки. Количество мест, где живёт значение, растёт без всякого злого умысла.

Разумнее разделить два слоя:

  • настройки задачи хранят имя секрета, например marketplace_token_sever;
  • само значение лежит отдельно и подтягивается процессом при запуске.

Тогда конфиг можно показывать коллегам, класть под контроль версий и вставлять в тикет. В нём нет ничего, что давало бы доступ к API площадки. Заодно появляется явная связка «магазин — ключ»: в настройках экземпляра для «Юга» записано имя marketplace_token_yug, и перепутать магазины сложнее, чем когда оба токена лежат под похожими переменными в одном окружении. Ошибка вида «заказы Юга ушли в CRM Севера» нередко рождается именно из такой путаницы: код берёт ключ по неверному суффиксу.

Практическая деталь: имя секрета лучше делать несущим магазин и назначение, а не просто «token1». Через полгода по имени должно быть понятно, чей это ключ и куда он ведёт. А вот в само значение ничего зашивать не нужно: не придумывайте токены, в которых закодирован магазин, значением управляет внешний сервис.

Compose secrets: что именно получает контейнер

Если сервисы запущены через Docker Compose, у него есть механизм секретов, подходящий под нашу карту. Согласно описанию секретов в Docker Compose, доступ выдаётся конкретному сервису явно: секрет сначала объявляется на верхнем уровне, а затем перечисляется в блоке secrets тех сервисов, которым он положен. Сервис, где секрет не указан, его не получит.

Для нашего примера конфигурация выглядит так:

services:
  orders-sync-sever:
    image: registry.example/orders-sync:1.4
    environment:
      MARKETPLACE_TOKEN_FILE: /run/secrets/marketplace_token_sever
      CRM_KEY_FILE: /run/secrets/crm_key_sever
    secrets:
      - marketplace_token_sever
      - crm_key_sever

  orders-sync-yug:
    image: registry.example/orders-sync:1.4
    environment:
      MARKETPLACE_TOKEN_FILE: /run/secrets/marketplace_token_yug
      CRM_KEY_FILE: /run/secrets/crm_key_yug
    secrets:
      - marketplace_token_yug
      - crm_key_yug

  stock-push:
    image: registry.example/stock-push:2.0
    secrets:
      - marketplace_token_sever
      - marketplace_token_yug

secrets:
  marketplace_token_sever:
    file: ./secrets/marketplace_token_sever.txt
  marketplace_token_yug:
    file: ./secrets/marketplace_token_yug.txt
  crm_key_sever:
    file: ./secrets/crm_key_sever.txt
  crm_key_yug:
    file: ./secrets/crm_key_yug.txt

Здесь в переменных окружения лежит только путь к файлу, а не значение. Это и есть ссылка из предыдущего раздела: приложение читает файл по пути из MARKETPLACE_TOKEN_FILE. Notifier в примере вообще не получает секретов площадки или CRM, его собственный токен бота объявляется отдельно и выдаётся только ему.

Два сервиса, два набора файлов

Посмотрим, что видит каждый процесс. Внутри контейнера orders-sync-sever в каталоге /run/secrets/ будут два файла: токен площадки и ключ CRM Севера. Файлов Юга там нет вообще. Контейнер stock-push увидит два токена площадки и ни одного ключа CRM. Если в stock-push найдут уязвимость и злоумышленник получит чтение файлов внутри контейнера, он вынесет два токена площадки, но не доберётся до CRM. Это и есть очерченный круг: набор файлов определяется конфигурацией, а не удачей.

Заметьте: stock-push здесь обслуживает оба магазина, и поэтому видит оба токена. Если эту границу нужно сузить, делается то же, что с синхронизацией: два экземпляра. Compose позволяет это без изменения кода, меняются только имена сервисов и списки секретов.

Чем механизм не является

Здесь легко додумать лишнее. Compose передаёт секрет файлом: по описанию, это монтирование файла в контейнер, причём речь о Linux containers и механизме bind mount. Из этого следует несколько вещей, которые полезно держать в голове.

  • Исходный файл ./secrets/marketplace_token_sever.txt на хосте остаётся обычным файлом. Он не зашифрован, и передача через Compose этого не меняет.
  • Тот, кто владеет хостом, например пользователь с root, прочитает исходные файлы напрямую, минуя контейнеры. Он же может выполнить команду внутри любого контейнера и прочитать /run/secrets/. Compose secrets не защищают от владельца хоста.
  • Разделение действует между сервисами Compose. Оно не заменяет права на файлы на самом хосте: если каталог ./secrets/ открыт для чтения всем локальным пользователям, граница между сервисами защитит лишь от компрометации одного контейнера, но не от постороннего процесса на хосте.

Поэтому права на каталог с исходными файлами нужно выставить явно. Например, владелец — отдельный служебный пользователь, права на каталог 700, на файлы 600. Тогда прочитать значения смогут этот пользователь и root. Это не абсолютная изоляция, но круг читателей на хосте сведён к минимуму и понятен.

Есть ещё нюанс, о котором стоит помнить при выборе правил чтения внутри контейнера: файл в /run/secrets/ читается процессом приложения. Значит, любой код внутри этого контейнера, включая подключённые библиотеки, может его прочитать. Если в образ попала сторонняя зависимость, она в этом же круге. Граница проходит по контейнеру, не по отдельной функции в коде.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Где значение всё же выплывает наружу

Допустим, карта построена, секреты розданы по сервисам. Теперь самое неприятное: значение не обязано оставаться в файле. Оно попадает туда, куда его кладёт сам процесс или окружающая инфраструктура. Пройдём по реальным путям.

Журналы приложения

Классический случай: сервис при ошибке логирует запрос целиком. Если токен передаётся в заголовке Authorization, а в журнал пишутся заголовки, значение окажется в лог-файле. Файл журнала читают другие люди и другие процессы: агент сбора логов, система мониторинга, инженер поддержки. Круг читателей токена теперь шире круга сервисов из карты.

Что с этим делать, зависит от кода. Не логировать заголовки авторизации целиком. Маскировать значения перед записью: оставлять, скажем, только последние четыре символа для опознания. Если интеграция передаёт токен в строке запроса (некоторые API допускают это), адрес запроса с токеном тоже попадёт в журнал, и здесь маскировать приходится уже URL. Проверьте, куда ваш код пишет сообщения об ошибках HTTP-клиента: многие библиотеки включают в текст исключения полный адрес.

Переменные окружения и диагностика

Если секрет передан не файлом, а переменной окружения, он виден в выводе docker inspect для контейнера, в дампе окружения, который иногда делает страница диагностики или отладочный эндпоинт, и в отчёте о сбое, куда некоторые системы приложат окружение процесса. Файловая схема из предыдущего раздела снижает этот риск: в окружении лишь путь. Но вывод не следует доводить до лозунга «файл всегда безопаснее». Файл тоже имеет свои пути утечки:

  • приложение может при старте напечатать содержимое прочитанного файла в журнал «для отладки»;
  • дамп памяти процесса содержит прочитанное значение независимо от того, откуда оно получено;
  • каталог с исходными файлами может попасть в архив проекта или в резервную копию хоста;
  • если исходный секрет лежит в репозитории рядом с compose.yaml, он окажется в истории версий, откуда его неудобно убирать.

Выходит, что разница не в «безопасно или нет», а в том, сколько вторичных мест сопровождает каждый способ. Переменные окружения тянутся за процессом в инспекторы и дочерние процессы, файл остаётся ограниченным конкретным путём, но его нужно исключить из копий и репозиториев. Оба варианта требуют проверять собственные журналы и дампы.

Резервные копии и административный доступ

Резервная копия каталога проекта унесёт и ./secrets/, если вы её явно не исключили. Тогда токены окажутся в хранилище копий с другими правами доступа и другим сроком жизни: через полгода в архиве всё ещё лежит токен, который давно заменён, а ещё хуже, если не заменён. Решение либо исключить каталог с секретами из общих копий и хранить их отдельно, с отдельными правами, либо считать копию такой же чувствительной, как сами файлы, и защищать её соответственно. Среднего не бывает: копия, доступная широкому кругу, обнуляет всю разводку по сервисам.

С административным доступом проще и жёстче. Тот, у кого root на сервере, видит всё. Если на VPS работает несколько администраторов, каждый из них входит в круг читателей всех секретов на хосте. Сократить этот круг можно только организационно: меньше людей с root, отдельные учётные записи вместо общей, журнал входов. Технически Compose здесь ничего не изменит, о чём прямо говорит описание механизма: файловая передача не лишает владельца хоста доступа.

Для такой схемы удобно, когда сервер ваш и права администратора принадлежат вам. На VPS MAATRIX выдаётся root-доступ, поэтому и каталог секретов, и служебного пользователя, и права на файлы вы настраиваете сами, а значит и отвечаете за них сами. Это стоит учесть до выбора: ресурсов тарифа хватит под несколько небольших сервисов синхронизации, но у каждого дополнительного экземпляра есть своя доля памяти. Например, тариф Ba с 1 vCPU и 2 ГБ RAM подойдёт для пробы схемы, а для большего числа отдельных экземпляров разумно смотреть на Uab или выше. Это не обещание ёмкости: реальный запас покажет замер ваших сервисов.

Отзыв и ротация: заменить один токен, не задев остальные

Теперь проверим, ради чего вся разводка затевалась. Допустим, у «Юга» заподозрили утечку токена площадки: он мелькнул в журнале, и неизвестно, кто его видел. Нужно заменить именно этот токен, не останавливая работу «Севера». Памятка OWASP рекомендует поддерживать ротацию секретов и по возможности автоматизировать её, но это рекомендация безопасности: насколько ротация возможна, зависит от самой интеграции. Некоторые площадки выпускают новый токен самостоятельно, у других это делается в кабинете вручную, у третьих старый токен перестаёт работать сразу после выпуска нового.

Порядок для нашего случая такой:

  1. Выпустить новый токен для «Юга» в кабинете площадки. Выясните заранее, живёт ли старый токен до отзыва или умирает сразу. От этого зависит, будет ли пауза.
  2. Записать новое значение в ./secrets/marketplace_token_yug.txt. Права на файл остаются прежними.
  3. Перезапустить только сервисы, которые используют этот секрет: orders-sync-yug и stock-push. Контейнеры «Севера» и notifier не трогаются, их файлы не менялись.
  4. Проверить, что новый токен работает: по журналу сервиса должен пройти успешный запрос, а не ошибка авторизации.
  5. Отозвать старый токен в кабинете, если он не отозван автоматически.
  6. Найти и очистить следы: журналы, где старое значение могло оказаться, и копии каталога секретов.

Сразу видно, что даёт карта доступа. Благодаря ей, мы знаем точный список сервисов к перезапуску: два из трёх. Без карты пришлось бы перезапускать всё и гадать, что ещё могло читать старый токен. Заодно карта подсказывает, где искать следы: утечка могла произойти только в процессах из этого списка и в их журналах.

Теперь про паузу. Если старый токен отзывается сразу при выпуске нового, между выпуском и перезапуском сервисов «Юга» запросы будут завершаться ошибкой авторизации. Для остатков это терпимо: следующий цикл отправит актуальные данные. Для заказов важно, чтобы сервис умел повторять неудавшиеся попытки и не терял непрочитанное. Поэтому перед ротацией стоит заранее выяснить, как ваш сервис ведёт себя при ошибке авторизации: падает, зацикливается или спокойно ждёт новый запуск.

Если секреты задаются файлами, обновление значения не всегда подхватывается работающим процессом: многие приложения читают файл один раз при старте. Поэтому перезапуск после замены файла нужен, а не желателен. Проверьте это на своём приложении, а не предполагайте.

Что ротация не лечит

Замена токена закрывает дверь, через которую могли войти, но не отменяет того, что уже сделали. Если с утёкшим токеном успели прочитать заказы, эти данные уже у третьей стороны. Ротация и узкая область токена вместе сокращают окно возможностей, но по отдельности не заменяют разбор журналов площадки: по ним видно, какие запросы шли с этим токеном и с каких адресов, если площадка такой журнал предоставляет.

Что у вас получается в итоге

Вернёмся к исходному файлу .env. После всех шагов картина другая. Секреты лежат отдельными файлами, у каждого своё имя с указанием магазина и назначения. Настройки задач хранят имена и пути, но не значения. Compose выдаёт каждый секрет только тем сервисам, которые перечислены в карте, а процессы «Севера» и «Юга» при необходимости разведены на отдельные экземпляры. Права на каталог с исходными файлами заданы явно, из копий и репозиториев он исключён, а журналы приложений не печатают заголовки авторизации.

Теперь спросим себя о худшем: что будет, если взломают один прикладной процесс? Допустим, злоумышленник получил выполнение кода в stock-push. Он прочитает два токена площадки: «Севера» и «Юга», потому что этот сервис обслуживает оба. Ключи CRM, токен бота уведомлений и всё остальное ему не доступны, пока он остаётся внутри этого контейнера. А если взломан orders-sync-yug, под угрозой токен площадки и ключ CRM «Юга», но не «Севера». Граница не абсолютна: от владельца хоста она не защищает, а если злоумышленник выберется из контейнера на хост, перед ним окажутся все исходные файлы, поэтому права на каталог и закрытый root так же важны, как сам Compose. Но набор потенциальных потерь теперь можно перечислить заранее, по строкам таблицы, и этого достаточно, чтобы решить, какой замене токенов и каким извещениям клиентов нужно быть готовым.

Если на одном сервере у вас уже живёт несколько сайтов или сервисов, ту же логику «каждому своё и ничего лишнего» полезно распространить и на их размещение, об этом подробнее в материале про настройку нескольких сайтов на одном VPS.