Пайплайн может забирать токены и ключи прямо в момент выполнения задания, если связать GitLab CI или GitHub Actions с внешним хранилищем: HashiCorp Vault, Bitwarden Secrets Manager или 1Password Connect. В переменных репозитория тогда остаётся только идентификатор роли или короткоживущий токен доступа, а сам секрет выдаётся на минуты и не оседает в истории сборок.
Механика у трёх хранилищ похожая: раннер аутентифицируется (JWT от GitLab, OIDC-токен от GitHub, AppRole или машинный токен), забирает значение через CLI или REST и подставляет его в шаг сборки. Ниже рабочие конфигурации для каждого варианта, настройка маскирования логов, разграничение доступа по окружениям, ротация секретов и аудит их использования.
Типовой набор секретов пайплайна: пароли к базам, deploy-токены, ключи подписи артефактов, ключи к внешним API, включая ключи к моделям ИИ, если сборка генерирует changelog или документацию. Для последнего случая удобен агрегатор вроде AiTunnel: один ключ на GPT, Gemini и Claude, лимиты бюджета на каждый ключ, оплата в рублях без VPN. Чем меньше мест, где лежат эти значения, тем меньше работы при ротации. Общий каркас защищённого пайплайна разобран в статье Практическое руководство по DevOps и CI/CD: безопасный и воспроизводимый pipeline.
Зачем хранить секреты вне CI/CD и как это работает
Почему переменные репозитория - не лучшее место для секретов
Маскирование в GitLab и GitHub сравнивает вывод с точным значением строки. Любое преобразование ломает защиту: base64, URL-encode, разбивка на подстроки, добавление переносов. Команда echo "$SECRET" | base64 выведет в лог читаемое значение, и уже ни GitLab, ни GitHub его не скроет.
Хуже с секретами, которые пайплайн получает в рантайме. Vault token, выданный на 10 минут, нигде не помечен как masked, поэтому set -x или curl -v покажет его в логе целиком вместе с заголовком X-Vault-Token.
Доступ шире, чем кажется. В GitLab значение переменной читает любой участник с ролью Maintainer, который может запустить пайплайн на защищённой ветке. В GitHub Actions секреты наследуются в reusable workflows, а при определённых настройках и в запуски из форков. Журнала вида «кто и когда прочитал PROD_DB_PASSWORD» нет ни там, ни там: есть история запусков, но не история обращений к значению.
Ротация в переменных репозитория делается руками. Один и тот же пароль, разложенный по пяти проектам, приходится менять в пяти местах, и нет гарантии, что забытый проект не останется со старым значением. Protected variables в GitLab сужают круг доступа, но значение всё равно лежит в конфигурации репозитория и не оставляет следа о чтении.
Отдельная проблема - утечка через историю: старый токен в коммитах, лог предыдущей сборки, скачанный артефакт. Регулярная проверка нужна даже при аккуратной работе, чек-лист есть в статье Аудит безопасности DevOps-цепочки.
Как внешние хранилища выдают секреты в пайплайн
HashiCorp Vault проверяет подпись принесённого токена по JWKS-эндпоинту и сверяет claims с ролью. GitLab кладёт в ID token project_id, ref, environment и sub, GitHub в OIDC-токен кладёт sub вида repo:org/repo:environment:production. Если claims совпали, Vault возвращает клиентский токен с TTL 10-15 минут, и уже с ним пайплайн читает нужный path.
Bitwarden Secrets Manager выдаёт секреты по машинному access token. Долгоживущий BWS_ACCESS_TOKEN лежит в секрете CI/CD как bootstrap-креденшл, а сами значения читает CLI bws. Доступ ограничивается проектами, к которым привязан service account.
1Password Connect работает иначе: Connect Server разворачивается в вашей сети, кэширует секреты из облака и отдаёт их по REST. Раннер получает OP_CONNECT_HOST и OP_CONNECT_TOKEN, а CLI op тянет нужный item. Наружу уходит только синхронизация между Connect Server и облаком 1Password.
AppRole остаётся запасным вариантом для Vault, когда OIDC недоступен: например, на изолированном раннере без выхода к GitLab или GitHub. Тогда role_id и secret_id выдаются заранее, что снова увеличивает число долгоживущих креденшлов, поэтому OIDC предпочтительнее.
Интеграция HashiCorp Vault с GitLab CI
Настройка JWT-аутентификации в Vault
Включите метод jwt и опишите GitLab как источник токенов. jwks_url указывает на эндпоинт вашего GitLab, bound_issuer отсекает токены посторонних эмитентов.
vault auth enable jwt vault write auth/jwt/config \ jwks_url="https://gitlab.example.com/-/jwks" \ bound_issuer="https://gitlab.example.com" \ default_role="gitlab-prod"
Политика даёт доступ только к ветке секретов прод-окружения:
vault policy write gitlab-prod-read - <<EOF
path "secret/data/gitlab/prod/*" {
capabilities = ["read"]
}
EOF
Роль привязывает проект, ветку и окружение к политике. bound_audiences совпадает с aud из ID token, token_ttl ограничивает время жизни выданного токена.
vault write auth/jwt/role/gitlab-prod -tty=false \
role_type="jwt" \
user_claim="sub" \
bound_audiences="https://vault.example.com" \
bound_claims_type="string" \
bound_claims='{"project_id":"12345678","ref":"main","environment":"production"}' \
policies="gitlab-prod-read" \
token_ttl="10m" \
token_max_ttl="15m"
Проверить конфигурацию можно одним тестовым запуском: если project_id, ref или environment не совпадут, Vault ответит invalid jwt. Значения claims в GitLab строковые, поэтому project_id указывают в кавычках, иначе роль не совпадёт.
Пример .gitlab-ci.yml для получения секретов из Vault
Задание объявляет ID token с audience, который прописан в bound_audiences роли. Дальше два запроса: логин в auth/jwt/login и чтение секрета по path.
deploy:
stage: deploy
image: alpine:3.20
id_tokens:
VAULT_ID_TOKEN:
aud: https://vault.example.com
variables:
VAULT_ADDR: "https://vault.example.com"
REGISTRY: "registry.example.com"
DB_PATH: "secret/data/gitlab/prod/db"
before_script:
- apk add --no-cache curl jq docker-cli
script:
- |
set -euo pipefail
VAULT_TOKEN=$(curl -sS --request POST \
--data "$(jq -nc --arg jwt "$VAULT_ID_TOKEN" '{jwt:$jwt,role:"gitlab-prod"}')" \
"$VAULT_ADDR/v1/auth/jwt/login" | jq -r .auth.client_token)
test -n "$VAULT_TOKEN" -a "$VAULT_TOKEN" != "null"
DB_PASSWORD=$(curl -sS --header "X-Vault-Token: $VAULT_TOKEN" \
"$VAULT_ADDR/v1/$DB_PATH" | jq -r .data.data.password)
test -n "$DB_PASSWORD" -a "$DB_PASSWORD" != "null"
echo "$DB_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
unset VAULT_TOKEN DB_PASSWORD
environment:
name: production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
Разбор по шагам. jq -nc собирает тело запроса, второй jq -r вытаскивает клиентский токен и пароль, test -n ловит ситуацию, когда путь указан неверно и вместо значения приходит null. docker login получает пароль через stdin, поэтому значение не попадает в список процессов. unset убирает переменные из окружения до конца задания.
В старых версиях GitLab для аутентификации использовалась предопределённая переменная CI_JOB_JWT. Сейчас поддерживаемый путь - блок id_tokens с явным aud: переменная живёт только внутри задания, а audience мешает переиспользовать токен в другом сервисе. Если обновить GitLab нельзя, CI_JOB_JWT продолжает работать, но перейти на id_tokens стоит при первой возможности.
Вместо curl можно поставить Vault CLI и выполнить vault kv get -field=password secret/gitlab/prod/db с переменной VAULT_TOKEN. CLI добавляет к образу десятки мегабайт, зато сам разбирается с редиректами и версиями KV v2. Как из такого задания публиковать собранные образы, разобрано в материале про интеграцию реестра образов с CI/CD пайплайнами.
Интеграция Bitwarden Secrets Manager с GitHub Actions
Создание service account и получение access token
В веб-кабинете Bitwarden откройте Secrets Manager и создайте service account. Отметьте только те проекты, которые нужны пайплайну, и выдайте уровень Can read: сборке почти никогда не нужно записывать секреты. Access token показывается один раз при создании, скопируйте его сразу.
Практика для команд: отдельный service account на окружение и отдельный проект под сервис. Тогда отзыв одного токена не задевает остальные пайплайны, а в журнале событий видно, какой машинный аккаунт читал значения. Токен сохраните в GitHub как environment secret с именем BWS_ACCESS_TOKEN: секреты окружения доступны только заданиям, которые явно указывают это окружение.
Пример workflow GitHub Actions с bws CLI
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Install bws
run: npm install -g @bitwarden/bws
- name: Fetch secret
env:
BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
run: |
SECRET_ID=$(bws secret list --output json | jq -r '.[] | select(.key == "PROD_DB_PASSWORD") | .id')
DB_PASSWORD=$(bws secret get "$SECRET_ID" --output json | jq -r .value)
echo "::add-mask::$DB_PASSWORD"
echo "DB_PASSWORD=$DB_PASSWORD" >> "$GITHUB_ENV"
- name: Push image
run: |
echo "$DB_PASSWORD" | docker login registry.example.com \
-u "${{ vars.REGISTRY_USER }}" --password-stdin
Детали, которые чаще всего ломают этот шаг. Утилита jq уже есть на ubuntu-latest, отдельная установка не нужна. Маскирование через echo "::add-mask::$DB_PASSWORD" действует до конца задания, поэтому вызывать его нужно сразу после чтения значения и до любых выводов. Значение передаётся в следующий шаг через GITHUB_ENV, чтобы не дублировать запросы к Bitwarden и не хранить секрет в outputs.
Если задание печатает значение до add-mask, оно останется в логе навсегда, даже после повторной маскировки. Проверяйте шаги с npm, docker и aws: они любят писать конфигурацию целиком.
Интеграция 1Password Connect с GitLab CI
Развёртывание 1Password Connect Server
Connect Server состоит из двух контейнеров: connect-api отдаёт секреты по REST, connect-sync обновляет кэш из облака. Токен создаётся в веб-кабинете 1Password в разделе Integrations > Connect, там же скачивается файл 1password-credentials.json. Токен привязывается к конкретным vault, поэтому сервис видит только нужные хранилища.
Оба контейнера читают одни и те же файлы:
docker run -d --name connect-api --restart unless-stopped \ -p 127.0.0.1:8080:8080 \ -v /etc/op-connect/1password-credentials.json:/home/opuser/.op/1password-credentials.json:ro \ -v /etc/op-connect/connect.token:/home/opuser/.op/connect.token:ro \ 1password/connect-api:latest docker run -d --name connect-sync --restart unless-stopped \ -v /etc/op-connect/1password-credentials.json:/home/opuser/.op/1password-credentials.json:ro \ -v /etc/op-connect/connect.token:/home/opuser/.op/connect.token:ro \ 1password/connect-sync:latest
Порт публикуется только на localhost или приватный интерфейс: Connect Server не умеет TLS, а токен в заголовке Authorization уходит открытым текстом, если выставить сервер в интернет. Раннеры в другой подсети подключайте через reverse proxy с сертификатом. Для размещения самого сервера подойдёт отдельный узел в вашем контуре, например VPS в Timeweb Cloud с приватной сетью между сервером и раннерами.
Файл connect.token читается только на старте, поэтому при ротации токена контейнер нужно перезапустить. Держите его с правами 600 и не кладите в git рядом с docker-compose.yml.
Пример .gitlab-ci.yml для получения секретов через op CLI
deploy_prod:
stage: deploy
image: debian:bookworm-slim
variables:
OP_CONNECT_HOST: "https://op-connect.internal:8080"
OP_ITEM_ID: "a1b2c3d4e5f6g7h8i9j0klmnop"
REGISTRY: "registry.example.com"
script:
- |
set -euo pipefail
apt-get update > /dev/null
apt-get install -y --no-install-recommends curl unzip jq ca-certificates docker.io > /dev/null
curl -sSfo /tmp/op.zip "https://cache.agilebits.com/dist/1P/op2/pkg/v2.30.0/op_linux_amd64_v2.30.0.zip"
unzip -o /tmp/op.zip -d /usr/local/bin
chmod +x /usr/local/bin/op
DB_PASSWORD=$(op item get "$OP_ITEM_ID" --vault Production --fields password --format json | jq -r .value)
test -n "$DB_PASSWORD" -a "$DB_PASSWORD" != "null"
echo "$DB_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
environment:
name: production
rules:
- if: '$CI_COMMIT_TAG'
Переменные OP_CONNECT_HOST и OP_CONNECT_TOKEN добавьте в Settings > CI/CD > Variables с флагами Masked и Protected. Токен не передавайте аргументом командной строки: op читает его из окружения, а значение переменной не попадёт в лог, пока вы не выведете его сами. Номер версии CLI в URL замените на актуальный из списка релизов 1Password: старые сборки op не разбирают часть полей Connect.
Проверить связку удобно одной командой до сборки: op vault list должен вернуть список доступных хранилищ. Если приходит ошибка авторизации, проверьте, что Connect Server получил корректный connect.token и что время на раннере синхронизировано с сервером.
Маскирование секретов в логах GitLab CI и GitHub Actions
GitLab маскирует значения переменных, у которых в Settings > CI/CD > Variables включён флаг Masked. Требования к значению: одна строка, не короче восьми символов, только буквы, цифры и знаки @ : . ~ _ - + / =. Флаг Protected дополнительно ограничивает переменную защищёнными ветками, тегами и раннерами.
GitHub маскирует всё, что пришло через secrets.*, а для значений, полученных в рантайме, есть команда echo "::add-mask::$VALUE". Маска действует на весь оставшийся лог задания, включая последующие шаги.
Ограничение у обеих платформ одно: сравнение идёт с точной строкой. base64, JSON-экранирование, разбивка на куски по четыре символа, замена одной буквы или лишний перевод строки отключают защиту. Значит, полагаться на маскирование как на единственный барьер нельзя.
Практические правила для пайплайнов:
- Не выводите секреты в лог: ни через echo, ни через printenv, ни через set -x. Трассировка печатает команды целиком, включая заголовки с токенами.
- Не включайте CI_DEBUG_TRACE и ACTIONS_STEP_DEBUG на защищённых ветках: отладочный вывод расширяет объём печатаемых данных.
- Передавайте значения через stdin или переменные окружения, а не аргументами командной строки: аргументы видны в списке процессов и в трейсе раннера.
- Не складывайте секреты в artifacts, cache и outputs между заданиями: файлы кэша живут дольше одного пайплайна.
- Не собирайте URL с токеном внутри: git clone с credentials в адресе оставляет строку в логе и в конфиге репозитория.
Если секреты всё же попадают в Kubernetes, отдельный материал про Kubernetes Secrets объясняет, чем base64-обёртка отличается от шифрования etcd и как ограничить чтение секретов на уровне RBAC.
Ограничение доступа секретов по окружениям
В GitLab CI окружение задаётся в задании через environment: name, а переменные проекта и группы получают область видимости (environment scope). Переменная со scope production не попадёт в задание со scope staging, даже если оно запущено из той же ветки. Защита усиливается ключевыми словами: protected branches пропускают пайплайн только из main или по тегу, protected environments ограничивают список тех, кто может деплоить в прод.
В GitHub Actions окружения (Environments) дают больше: правила защиты позволяют требовать ревьюера, задавать таймер ожидания и перечислять ветки, из которых разрешён деплой. Секреты, привязанные к environment production, не видны заданию без environment production, а OIDC-токен такого задания содержит sub вида repo:org/repo:environment:production.
На стороне хранилища разграничение повторяет ту же логику:
| Слой | GitLab CI | GitHub Actions | Хранилище |
|---|---|---|---|
| Набор секретов по окружению | Область видимости переменной | Секреты окружения | Отдельные path, проекты или vault |
| Ограничение по ветке | Protected branches и tags | Deployment branches | bound_claims ref или sub |
| Одобрение перед продом | Protected environments | Required reviewers | Роль с коротким TTL |
| Отзыв доступа | Удаление переменной и правила | Правка защиты окружения | Ротация токена и правка политики |
В Vault под каждое окружение создаётся свой path и своя политика, а роль привязывается к конкретному project_id и ref. Отдельная роль на репозиторий даёт ещё одно преимущество: в аудит-логе видно, какой именно сервис читал секрет. В Bitwarden роль окружения выполняет отдельный service account с доступом только к своему проекту. В 1Password Connect границей служит vault, а токен доступа ограничивается списком хранилищ.
Ротация секретов и аудит использования из пайплайнов
Vault закрывает тему ротации динамическими секретами. Database secrets engine выдаёт логин и пароль к PostgreSQL на час, привязывает их к lease и удаляет учётную запись после истечения срока. Пайплайн не хранит пароль к базе вообще: скрипт забирает креденшл через vault read database/creds/app и работает, пока живёт lease. Для статических KV-секретов ротация остаётся ручной или скриптовой, зато версии хранятся и откат к предыдущему значению занимает одну команду.
Аудит в Vault включается один раз:
vault audit enable file file_path=/var/log/vault_audit.log
В лог попадают путь запроса, имя роли, accessor токена, время и адрес клиента, а значения секретов хэшируются. Если у каждого репозитория своя роль, по логу видно, какой пайплайн читал прод-пароль и когда. Токены, выданные GitLab и GitHub, живут 10-15 минут, поэтому украденный токен почти бесполезен после завершения задания.
Bitwarden Secrets Manager пишет события организации: кому и когда выдавался access token, какие секреты читал машинный аккаунт. Ротация сводится к генерации нового токена и удалению старого, после чего достаточно обновить один environment secret. 1Password Connect отдаёт журнал обращений к серверу и общий аудит-лог в тарифах Business, а токен Connect меняют вместе с перезапуском контейнеров.
Рабочий ритм ротации для bootstrap-креденшлов, то есть для BWS_ACCESS_TOKEN и OP_CONNECT_TOKEN: раз в 90 дней, с задачей в трекере и проверкой, что старый токен отозван. Для статических секретов в Vault включите хранение версий и проверьте, что пайплайны не завязаны на конкретное значение.
Сравнение хранилищ и выбор подходящего
| Критерий | HashiCorp Vault | Bitwarden Secrets Manager | 1Password Connect |
|---|---|---|---|
| Где работает | Self-hosted или HCP Vault | Облако Bitwarden | Connect Server в своей сети плюс облако 1Password |
| Аутентификация из CI | JWT, OIDC, AppRole, Kubernetes | Машинный access token | Connect token и CLI op |
| Динамические секреты | Есть: базы, облака, PKI | Нет, значения статические | Нет, значения статические |
| Ротация | Автоматическая для динамических, версии для KV | Вручную или через API | Вручную или через API |
| Аудит | Audit devices с детализацией по path и роли | События организации | Журнал Connect и аудит-лог Business |
| Порог входа | Высокий: политики, роли, unseal | Низкий: токен и CLI | Средний: свой сервер и файл учётных данных |
Выбор сводится к трём сценариям. Vault берут там, где есть базы, облачные аккаунты и требование короткоживущих креденшлов, а также команда, готовая сопровождать сервер и разбираться с политиками. Bitwarden Secrets Manager подходит небольшим командам, которым нужно закрыть задачу за вечер: один машинный токен, CLI и готовый журнал событий. 1Password Connect выбирают те, кто уже платит за 1Password и хочет держать секреты во внутренней сети, не отдавая их напрямую облачному API.
Начинайте с одного некритичного пайплайна. Уберите из переменных репозитория один секрет, выдайте его через OIDC и JWT, проверьте, что в логе нет ни значения, ни его base64-версии. Когда схема отработает, перенесите остальные секреты, разграничьте доступ по окружениям и включите аудит-лог. И пройдитесь по старой истории: если токен когда-то лежал в переменных, смените его, даже когда следов утечки не видно.