CI/CD-переменные и секреты: masked и protected
Учимся безопасно хранить токены и пароли через CI/CD-переменные проекта.
Masked-переменная — переменная, значение которой GitLab скрывает (маскирует) в логах джоб, чтобы секрет не утёк в вывод.
Где не место секретам
Класть пароли и токены прямо в .gitlab-ci.yml нельзя: файл лежит в репозитории, его видят все с доступом к коду, он попадает в историю Git. Секреты хранят в настройках проекта: Settings → CI/CD → Variables. Там переменная задаётся ключом и значением и автоматически прокидывается в джобы как обычная $VAR.
Главная коварность секрета в репозитории в том, что Git ничего не забывает. Даже если вы заметили токен в .gitlab-ci.yml через день и удалили его новым коммитом, прежнее значение навсегда останется в истории — его достанет любой, кто склонирует репозиторий и заглянет в старые коммиты. Удаление из текущего файла создаёт ложное ощущение безопасности: секрет всё ещё там, просто на пару коммитов глубже. Поэтому правильная реакция на утёкший в репозиторий токен — не «удалить и забыть», а немедленно отозвать и перевыпустить его на стороне сервиса, считая скомпрометированным. А чтобы вообще не попадать в эту ситуацию, секреты с самого начала держат вне кода — в CI/CD-переменных проекта, которые хранятся в базе GitLab, доступны только при выполнении джоб и никогда не оказываются в файлах, попадающих под контроль версий. Это смещает границу доверия: коду можно видеть имя $DEPLOY_TOKEN, но не его значение.
Masked: защита от утечки в логах
Флаг Masked заставляет GitLab заменять значение переменной на [MASKED] в выводе джоб. Даже если команда случайно напечатает токен, в логе его не будет. Маскирование имеет требования к формату значения (минимальная длина, без некоторых символов), иначе GitLab откажется маскировать — это защищает от ложного ощущения безопасности.
Зачем вообще нужна защита логов, если значение и так хранится отдельно от кода? Потому что логи джоб — это второй, легко упускаемый канал утечки. Команды в script постоянно что-то печатают, и секрет проскальзывает в вывод множеством незаметных способов: отладочный echo, утилита, которая логирует свою команду целиком вместе с переданным токеном, трассировка ошибки, дамп окружения. А логи пайплайна нередко видны шире, чем сам репозиторий, и хранятся долго — один случайный вывод, и секрет осел в истории джобы для всех желающих. Маскирование закрывает эту брешь, заменяя точное совпадение значения на [MASKED] в потоке логов. Но важно понимать его границы: требования к формату (минимальная длина, отсутствие части спецсимволов) существуют не из вредности, а потому что маскировать слишком короткое или «не уникальное» значение опасно — оно может случайно совпасть с обычным текстом или, наоборот, не сматчиться при малейшем преобразовании. GitLab честно отказывается маскировать такое значение, чтобы вы не строили защиту на иллюзии.
Protected: только защищённые ветки и теги
Флаг Protected ограничивает доступность переменной пайплайнами защищённых веток и тегов. Прод-секреты помечают protected, чтобы они не оказались доступны в пайплайне случайной feature-ветки, которую может открыть кто угодно. Связка masked + protected — стандарт для боевых токенов.
Эта пара флагов закрывает две принципиально разные угрозы, и их легко перепутать. Masked защищает от случайной утечки: секрет доступен джобе, но не должен попасть в логи. Protected защищает от намеренного доступа: секрет вообще не выдаётся джобам незащищённых веток. Представьте, что у внешнего контрибьютора или коллеги есть право создавать feature-ветки и пайплайны. Если прод-токен не помечен protected, такой человек может в своей ветке написать джобу, которая просто выведет $DEPLOY_TOKEN в файл-артефакт в обход маскирования (например, закодировав в base64) — и забрать боевой секрет. Флаг protected обрубает эту атаку на корню: на незащищённой ветке переменной в окружении джобы просто нет, печатать нечего. Вот почему боевые секреты всегда несут оба флага: masked — на случай неосторожности своих, protected — на случай злого умысла или ошибки чужих, и работают они только в связке.
deploy-prod:
stage: deploy
script:
- echo "$DEPLOY_TOKEN" | login-to-prod # значение придёт из protected-переменной
rules:
- if: '$CI_COMMIT_BRANCH == "main"'Здесь DEPLOY_TOKEN задан в настройках как protected; джоба деплоит только из main, так что доступ к секрету согласован с защитой ветки.
Скоуп по окружениям
Переменную можно ограничить определённым environment (production, staging). Тогда у джобы, деплоящей в staging, будет staging-секрет, а у прод-джобы — прод-секрет, даже если имя переменной одно. Это удобно для одинаковых по смыслу, но разных по значению ключей разных сред.
Скоуп по окружениям решает практическую боль больших проектов: один и тот же по смыслу секрет — например, API_KEY или строка подключения к базе — должен иметь разное значение для staging и для production. Без скоупа пришлось бы плодить переменные с уродливыми именами вроде API_KEY_STAGING и API_KEY_PROD и аккуратно подставлять нужную в каждой джобе вручную, рискуя однажды деплоем в staging задеть боевую базу. Со скоупом имя остаётся одним — API_KEY, — а GitLab сам подставит то значение, что привязано к окружению, в которое деплоит конкретная джоба. Это не только удобнее, но и безопаснее: прод-значение физически не попадёт в staging-джобу, даже если кто-то перепутает. Окружения здесь работают как естественная граница изоляции секретов, совпадающая с границей сред развёртывания.
Как работает под капотом
При формировании окружения джобы GitLab проверяет флаги каждой переменной: если protected, а пайплайн идёт по незащищённой ветке — переменная просто не добавляется в окружение. Masked-значения сервер при стриминге логов ищет и заменяет на [MASKED]. Скоуп по environment учитывается на основе того, в какое окружение деплоит джоба (ключ environment).
Полезно увидеть всю цепочку как последовательность решений, которые сервер принимает в момент старта джобы. Сначала GitLab собирает список переменных-кандидатов и по каждой сверяется с контекстом: ветка защищённая или нет, в какое окружение идёт джоба. Protected-переменная на незащищённой ветке отсеивается на этом шаге — она не попадает в окружение вовсе, поэтому никакая команда внутри джобы не сможет её прочитать; защита работает на уровне выдачи, а не маскирования. Затем сформированное окружение передаётся раннеру, и джоба выполняется. Маскирование же — отдельный, более поздний механизм: оно срабатывает не при выдаче переменных, а при стриминге логов обратно на сервер, где GitLab ищет в потоке точные совпадения masked-значений и подменяет их на [MASKED]. Из этой архитектуры следует важный нюанс: маскирование ловит только дословное совпадение. Если код преобразует секрет — закодирует в base64, разобьёт на части, выведет посимвольно — сервер не распознает его в логе и не замаскирует. Вот почему опытные инженеры не доверяют маскированию как последней линии обороны: protected и скоупы решают, кто получит секрет, и это надёжно; masking лишь подстраховывает от дословной печати, и обходится тривиально. Лучшая практика — вообще не печатать секреты, а маскирование держать как страховку, а не как защиту.
Частые ошибки
- Печатать секрет через
echoи полагаться только на masking — лучше вообще не печатать; маскирование не покрывает все случаи (например, base64-кодирование секрета его «размаскирует»). - Забыть protected на прод-секрете — и он станет доступен любой feature-ветке.
- Коммитить
.envс секретами в репозиторий вместо использования CI/CD-переменных. - Считать, что удалённый из файла секрет исчез, — он остаётся в истории Git, скомпрометированный токен нужно отзывать и перевыпускать.
- Множить
KEY_STAGING/KEY_PRODвместо скоупа по environment — выше риск перепутать значения сред.
Итоги
- Секреты — в CI/CD-переменных проекта, не в файле пайплайна.
Maskedпрячет значение в логах,Protectedограничивает защищёнными ветками/тегами.- Скоуп по environment даёт разные значения одной переменной для разных сред.
- Masked и protected закрывают разные угрозы — случайную утечку и намеренный доступ — и работают в связке.
- Маскирование ловит лишь дословное совпадение и обходится преобразованием; не стройте на нём защиту, лучше вовсе не печатать секреты.