Интеграция хранилищ секретов с CI/CD: HashiCorp Vault, Bitwarden и 1Password в GitLab CI и GitHub Actions | AdminWiki

Интеграция хранилищ секретов с CI/CD: HashiCorp Vault, Bitwarden и 1Password в GitLab CI и GitHub Actions

15 сентября 2026 13 мин. чтения

Пайплайн может забирать токены и ключи прямо в момент выполнения задания, если связать 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 CIGitHub ActionsХранилище
Набор секретов по окружениюОбласть видимости переменнойСекреты окруженияОтдельные path, проекты или vault
Ограничение по веткеProtected branches и tagsDeployment branchesbound_claims ref или sub
Одобрение перед продомProtected environmentsRequired 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 VaultBitwarden Secrets Manager1Password Connect
Где работаетSelf-hosted или HCP VaultОблако BitwardenConnect Server в своей сети плюс облако 1Password
Аутентификация из CIJWT, OIDC, AppRole, KubernetesМашинный access tokenConnect 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-версии. Когда схема отработает, перенесите остальные секреты, разграничьте доступ по окружениям и включите аудит-лог. И пройдитесь по старой истории: если токен когда-то лежал в переменных, смените его, даже когда следов утечки не видно.

Поделиться:
Сохранить гайд? В закладки браузера