env-master
ENСоздать аккаунт

Убрать секреты из CI — значит оставить там ровно две строки

Когда работающий сервис переводят на внешнее хранилище секретов, ожидание обычно одно: «в настройках CI станет пусто». Не станет. Там остаются ровно две строки — и понимание, почему именно эти две, важнее самого списка.

Ниже — разбор реального переезда: сервис доставки обновлений, монорепозиторий на bun, docker compose на одном хосте, CI в Gitea Actions. Переезд шёл без окна простоя и без «большого переключения». Пошаговая инструкция — в документации по внедрению; здесь — почему порядок шагов именно такой и что ломается, если его нарушить.

Что остаётся в настройках CI

Две строки, и только они:

  • ENV_MASTER_CLIENT_ID — переменной, а не секретом. Тайны в идентификаторе нет, а видимый в интерфейсе идентификатор экономит время при разборе инцидента. В workflow он читается как vars.ENV_MASTER_CLIENT_ID || secrets.ENV_MASTER_CLIENT_ID — тогда переезд из секретов в переменные не ломает прогон.
  • ENV_MASTER_CLIENT_SECRET — только секретом, без запасного варианта.

Всё остальное — адрес хоста, пользователь, порт, путь выкладки, отпечаток хоста, реестр образов, приватный ключ деплой-пользователя — это уже не настройки CI, а ключи окружения проекта в env-master.

«Убрать секреты из CI» не значит «убрать все». Эти две строки — не секреты приложения, а пропуск к хранилищу. И важнее всего то, что этот пропуск открывает не всё.

Две identity, и это главное в конструкции

КтоКак представляетсяЧто видит
ПайплайнENV_MASTER_CLIENT_ID + ENV_MASTER_CLIENT_SECRET, universal-режимтолько ключи выкладки
Сервис на хостеmachine identity из файла на дискетолько ключи рантайма

Universal-режим — постоянные client_id и client_secret: раннер поднимается заново на каждый прогон, и одноразовый enrollment-токен ему не подходит. У identity пайплайна список разрешённых ключей сужен (allowedKeys) до тех, что нужны выкладке.

Следствие, ради которого всё и делается: identity пайплайна не видит прикладных ключей вовсе. Скомпрометированная сборка не отдаёт JWT_SECRET — не потому что её «хорошо настроили», а потому что прочитать его ей нечем. Это действующее свойство, из которого вытекает половина остальных решений — включая историю с проверкой ниже.

Порядок, который нельзя развернуть

Переезд — это три движения, и порядок у них не косметический:

  1. Выкладка перестаёт хранить конфигурацию у себя. Ключи уезжают в env-master, самодельный SSH заменяется чтением из хранилища.
  2. Конфигурацию проверяют. Отдельным шагом, до первого SSH — до того, как выкладка что-то тронет на хосте. Проверка, падающая на середине деплоя, оставляет сервис в промежуточном состоянии.
  3. Конфигурацию перестаёт видеть тот, кому она не нужна. Права identity сужаются, лишние сервисы выкидываются из стека вместе с их доступами.

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

Проверка, которая увела в противоположную сторону

Это случилось на самом деле. При первом сужении прав пайплайн упал с требованием завести JWT_SECRET и PUBLIC_API_URL — ключи, которые честно лежали в окружении. Причина: проверка сверяла полный список, а identity пайплайна уже видела только свою половину. Для невидимого ключа она выдавала «ключ не приехал из env-master» — и человек шёл заводить ключ, который уже есть, вместо того чтобы чинить проверку.

Отсюда правило: пайплайн проверяет только свою половину — ключи, помеченные для выкладки (deploy и both в контракте окружения). Это не экономия: чужую половину он проверить не способен, а попытка даёт сообщение, уводящее в противоположную сторону. Ключи рантайма проверяет само приложение при старте — там, где они нужны, и той identity, которой они видны. У каждого ключа в контракте сказано, кому он нужен (consumer), а ключ без пометки считается «нужен обоим»: забытая пометка должна ронять проверку громко, а не молча выводить ключ из-под неё.

Монтирование, которого не видно в журнале

Единственный секретный файл на сервере после переезда — удостоверение машины. Оно лежит в каталоге проекта: /etc/envmaster/<проект>, владелец root, права 0700. Владелец именно root, а не деплой-пользователь: контейнер работает от root и каталог читает, а деплой-пользователь — то есть всё, что приезжает по SSH из пайплайна, — секрет машины видеть не должен. В контейнер монтируется каталог целиком, а не один файл: SDK пишет machine.json через временный файл рядом и rename, а rename не работает через границу монтирования одиночного файла.

На хосте с несколькими проектами — каталог на проект, и монтируется ровно один:

volumes:
  - /etc/envmaster/my-project:/run/envmaster   # так
# - /etc/envmaster:/run/envmaster              # НЕ так

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

PEM уходит в лог по строке за раз

Приватный ключ деплоя лежит в env-master, как и всё остальное. Но раннер CI маскирует секрет только целиком — а многострочный PEM попадает в вывод построчно, и маскировка целого значения его не ловит: с ранней версией действия ключ оказался бы в журнале прогона в открытом виде. Поэтому версия, которой пайплайн получает ключи, маскирует PEM построчно — каждую строку, кроме заголовка. Это исправление реальной ловушки, а не украшение: утечка по строке за раз неотличима от обычного вывода, пока её не увидишь.

Что в итоге

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

Полный порядок — шесть шагов, каждый останавливаем отдельно, — в документации по внедрению в готовый продукт. Про то, как сервис доказывает, что он — это он, — в статье про машинную identity, а про журнал, о котором шла речь выше, — в статье про аудит доступа к секретам.

← Все статьи