vyvchy
    Теми розділу

    09 · Git і CI/CD

    Секрети та конфігурація в CI

    Зміст

    Ваш пайплайн має якось залогінитися в застосунок, сходити в базу, смикнути платіжний шлюз-пісочницю й покласти артефакти в хмарне сховище. Для всього цього потрібні паролі, токени й ключі — і рано чи пізно постає питання: де їх тримати, щоб автотести працювали, але секрет не опинився у git-історії, у логах джоба чи в скриншоті на весь екран. Гігієна секретів — це не «безпекова параноя для окремих людей», а базова інженерна дисципліна: один закомічений токен здатний коштувати команді екстреної ротації всіх доступів у суботу вночі.

    Ця глава — поглиблення: якщо ви проходите теорію CI вперше й ще не налаштовували власний пайплайн, її можна пропустити й повернутися тоді, коли реально доведеться передавати креденшели в CI. Але на співбесідах середнього рівня питання «секрет випадково закомітили — що робите?» звучить регулярно, і правильна відповідь тут не інтуїтивна. Далі — механізм: чим секрет відрізняється від звичайної змінної, як працює (і де не працює) маскування, що робити після витоку, як віддавати креди тестових юзерів і чому пул-реквести з форків навмисне позбавлені доступу до секретів.

    Змінна чи секрет: у чому різниця

    CI розрізняє два типи конфігураційних значень, і плутати їх — типова помилка новачка. Змінна (variable) — це несекретна конфігурація: базовий URL стенду, назва браузера, таймаут, прапорець фічі, номер збірки. Її не страшно побачити в логах чи в баг-репорті. Секрет (secret) — це значення, розкриття якого шкодить: пароль, токен доступу, приватний ключ, рядок підключення до БД, ключ хмарного провайдера.

    Практичний критерій, який варто тримати в голові: чи можу я вставити це значення у публічний баг-репорт без шкоди? Якщо так — це змінна. Якщо ні — секрет, і поводитися з ним треба інакше.

    Технічно різниця не косметична. У GitHub Actions є окремі сутності: конфігураційні змінні (контекст vars) і секрети (контекст secrets). У GitLab CI будь-яка CI/CD-змінна має прапорці «Masked» і «Protected». У Jenkins секрети живуть у Credentials-сторі, а не в тексті пайплайна. Спільне в усіх системах те, що робить значення саме секретом:

    • воно зазвичай write-only в інтерфейсі — після збереження його не можна прочитати назад, лише перезаписати (у GitHub Actions і Jenkins так; частина систем, як-от GitLab, показує значення власнику проєкту);
    • воно шифрується at rest у сховищі CI;
    • воно маскується в логах — при виводі система замінює його на маску (у GitHub Actions це ***, у GitLab — [MASKED]).

    Змінна не має жодної з цих властивостей: її видно в UI, вона їде в лог як є. Тому механіка «просто прокинути env-змінну в джоб» (детальніше — Будова пайплайна) підходить для конфігурації, але для креденшелів потрібне саме секрет-сховище.

    Сховище секретів і маскування в логах

    Секрет-сховище (secret store) працює так: ви одноразово кладете значення в захищене сховище CI, а під час прогону система інжектить його в потрібний джоб — зазвичай як змінну середовища або як тимчасовий файл, який видаляється після джоба. У код тесту секрет приходить через process.env, і сам код ніколи не містить самого значення.

    // playwright.config.ts — секрет приходить з оточення, не з репозиторію
    export default defineConfig({
      use: {
        baseURL: process.env.BASE_URL ?? 'https://staging.example.com',
        extraHTTPHeaders: {
          Authorization: `Bearer ${process.env.API_TOKEN ?? ''}`,
        },
      },
    });

    Ключова гарантія сховища — маскування в логах. Коли значення секрета випадково потрапляє у stdout (наприклад, хтось залишив console.log), CI підмінює його на ***. Але тут криється найпоширеніша ілюзія безпеки: маскування — це best-effort пошук точного літерала, а не магічний захист самого значення. Система шукає в потоці логів ту саму послідовність символів, що лежить у сховищі. Варто цю послідовність трансформувати — і маскування її не впізнає.

    console.log(process.env.API_TOKEN);                              // -> ***  (замасковано)
    console.log(Buffer.from(process.env.API_TOKEN!).toString('base64')); // -> витік у відкритому вигляді

    Друга форма проходить повз маскування, бо в логах уже інший рядок. Так само пройде секрет, розбитий на частини, виведений посимвольно чи загорнутий в URL. Якщо ви обчислюєте секрет під час прогону (наприклад, отримали короткоживучий токен), у GitHub Actions його треба явно зареєструвати командою add-mask, інакше він не маскуватиметься автоматично.

    Ще небезпечніше те, що маскування діє лише на текст логів, але не на артефакти. Секрет, який потрапив у Playwright-трейс, у скриншот сторінки, у HAR-файл із тілами запитів або в JUnit-звіт, зберігається там у відкритому вигляді — незалежно від того, що консольний лог показує ***. Тому дисципліна логів (детальніше — Логи, моніторинг і спостережуваність) і акуратність з тим, що ви прикладаєте до фейлу, — частина гігієни секретів, а не окрема тема.

    Що ніколи не комітити і що робити після витоку

    Список того, що не має потрапляти в репозиторій, короткий і жорсткий: паролі, токени й API-ключі, приватні ключі й сертифікати, рядки підключення до БД, ключі хмарних провайдерів, файли .env із реальними значеннями, а також збережені сесії тестів (storageState). Стандартний захист першого рубежу — .gitignore для .env і подібних файлів (механіка — Системи контролю версій і модель Git).

    Але .gitignore має дві пастки, на яких ловляться навіть досвідчені. Перша: якщо файл уже відстежується git-ом, додавання його в .gitignore ні на що не впливає — ігнор діє тільки на невідстежувані файли. Друга, важливіша: git зберігає повну історію. Секрет, який ви закомітили, а наступним комітом «видалили», нікуди не зник — він лежить у попередньому коміті й дістається одним git show. Видалення файлу в новому коміті створює хибне відчуття, що проблему розв'язано.

    Звідси головне правило, і саме його перевіряють на співбесіді. Витік = компрометація. Секрет, який хоч на мить став доступним, вважається скомпрометованим — його не можна «розкомпрометувати». Тому перша й обов'язкова дія — ротація: відкликати старий секрет і випустити новий. Чистка git-історії (через git filter-repo чи BFG із подальшим force-push — механіка в главі Rebase, cherry-pick і переписування історії) — вторинна гігієна: вона прибирає значення з клонів, але не робить старий токен знову безпечним. Кандидат, який на це питання відповідає лише «зроблю rebase і почищу історію», не назвавши ротацію, — червоний прапорець.

    Секрет потрапив у репо або лог

    Вважати скомпрометованим

    Ротація: відкликати старий,
    випустити новий

    Оновити значення у сховищі CI

    Прибрати з історії:
    filter-repo / BFG + force-push

    Увімкнути secret scanning
    і pre-commit хук

    Секрет потрапив у репо або лог

    Вважати скомпрометованим

    Ротація: відкликати старий,
    випустити новий

    Оновити значення у сховищі CI

    Прибрати з історії:
    filter-repo / BFG + force-push

    Увімкнути secret scanning
    і pre-commit хук

    Профілактика дешевша за розбір інциденту. Хостинги вміють сканувати коміти на секрети (secret scanning) і навіть блокувати push із виявленим ключем (push protection). Локально той самий захист дає pre-commit хук з інструментом на кшталт gitleaks чи detect-secrets — він ловить секрет ще до коміту, до того, як той дістанеться віддаленого репозиторію.

    Креденшели тестових користувачів

    Автотестам потрібні акаунти для логіну — і тут з'являється спокуса «це ж просто тестовий юзер, покладу пароль прямо в код». Спокуса помилкова: конфіг у репозиторії — це креди в репозиторії. Креденшели тестових користувачів — теж секрети, хай і меншої цінності, тож живуть вони в секрет-сховищі й приходять у тест через оточення.

    const user = process.env.TEST_USER;
    const password = process.env.TEST_PASSWORD;
    if (!user || !password) {
      throw new Error('TEST_USER / TEST_PASSWORD не задано — перевірте секрети CI');
    }

    Раннє падіння з чіткою помилкою (fail fast) тут корисніше за туманний фейл усередині логіну: одразу видно, що проблема в конфігурації прогону, а не в застосунку.

    Кілька дисциплін, специфічних для тестових акаунтів:

    • Ніколи не реальні дані клієнтів. Тестовий прогін не повинен ходити під акаунтом справжнього користувача з продакшену — ні заради безпеки, ні заради чистоти даних. Виділений тестовий тенант чи набір сідованих акаунтів з мінімальними правами — норма (стратегія тестових даних — канон у розділі про автоматизацію).
    • storageState — це живий токен. Практика «залогінитися раз і перевикористати стан» (детальніше в розділі про інструменти автоматизації) зберігає сесійні cookie й токени у файл. Це повноцінний секрет: маючи його, можна зайти в застосунок без пароля (природа сесійних токенів — у главі Кукі, сесії та сховище браузера). Такий файл додають у .gitignore і генерують на кожному прогоні, а не комітять.
    • Ротованість. Пароль тестового юзера має бути так само легко замінити, як і будь-який інший секрет, — на випадок витоку через артефакт чи лог.

    Конфігурація під середовища

    Той самий набір тестів зазвичай бігає проти кількох середовищ: локально, на staging, іноді як смоук на проді. Змінюється базовий URL, набір фіч-прапорців, іноді набір даних — і, окремо, секрети. Принцип, який тут працює, сформульований у методології The Twelve-Factor App: конфігурація живе в оточенні, а не в коді. Для тестів це означає, що baseURL, вибір браузера й таймаути беруться з env, а не хардкодяться.

    Несекретну конфігурацію можна тримати в репозиторії — наприклад, окремими файлами під кожне середовище або через projects у конфізі Playwright. А ось секрети розкладаються по середовищах у сховищі й ключуються середовищем. Головний інваріант безпеки тут: секрет продакшену не має бути досяжним із пайплайна, який ганяє staging чи PR. Прод-креди й staging-креди — різні секрети з різними доступами.

    Механізми для цього є у всіх системах. GitHub Actions має Environments із власними секретами й правилами захисту: джоб, що деплоїть у прод, можна поставити за обов'язковим апрувом рев'юера, перш ніж він отримає доступ до прод-секретів. У GitLab захищені (protected) змінні віддаються лише пайплайнам на захищених гілках і тегах — тобто випадкова гілка до них не дотягнеться. Розкладка середовищ і деплой детальніше — у главі Середовища, деплой і релізи; окремо варто пам'ятати, що секрети не запікаються в Docker-образ (див. Docker і тестові середовища) — образ віддається багатьом, а секрет інжектиться в контейнер під час запуску.

    Секрети і PR з форків

    Це найтонше місце теми й улюблене питання інтерв'юерів на розуміння моделі довіри. Публічний проєкт приймає пул-реквести від будь-кого через форк. Код у такому PR — недовірений: його написала невідома людина. Якби пайплайн, який запускається на форк-PR, мав доступ до секретів, зловмиснику вистачило б додати в PR один крок, що виводить токен у лог (чи, як ми бачили, у base64) — і секрет витік би при першому ж прогоні.

    Саме тому в GitHub Actions подія pull_request із форка не отримує секретів узагалі, а вбудований GITHUB_TOKEN стає read-only. Це не збій конфігурації — це навмисний захист. Додатково хостинг вимагає ручного апруву на запуск воркфлоу для нових контриб'юторів. GitLab діє за тією ж логікою: гілка форку не є захищеною, тож protected-змінні їй недоступні.

    Окрема пастка — подія pull_request_target. Вона запускається в контексті базового репозиторію й має доступ до секретів, але за замовчуванням чекаутить базову гілку, а не код із PR. Небезпека виникає, коли воркфлоу явно робить чекаут коду пул-реквесту (head.sha) і потім виконує його скрипти складання чи тестів: тоді недовірений код біжить із повним доступом до секретів. Це класична діра, відома як pwn request. Правило просте: не виконуйте код із PR у джобі, який має секрети.

    Ні, гілка в тому ж репо

    Так, форк

    Ні: lint, typecheck, unit, build

    Так: e2e з кредами

    Відкрито Pull Request

    Гілка з форка?

    Секрети доступні джобу

    pull_request: секретів немає,
    GITHUB_TOKEN read-only

    Джобу потрібні секрети?

    Запускаємо прямо на PR

    Відкласти до мержа
    або запуск за апрувом мейнтейнера

    Ні, гілка в тому ж репо

    Так, форк

    Ні: lint, typecheck, unit, build

    Так: e2e з кредами

    Відкрито Pull Request

    Гілка з форка?

    Секрети доступні джобу

    pull_request: секретів немає,
    GITHUB_TOKEN read-only

    Джобу потрібні секрети?

    Запускаємо прямо на PR

    Відкласти до мержа
    або запуск за апрувом мейнтейнера

    Практичний висновок для AQA-пайплайна: розкладайте перевірки за довірою. Те, що секретів не потребує — лінт, перевірка типів, юніти, збірка — спокійно бігає на форк-PR і дає контриб'ютору швидкий фідбек. Те, що потребує кредів — e2e проти реального стенду — запускається після мержу або на довіреній основі (за апрувом мейнтейнера, за міткою). Зв'язок стратегій гілкування з форками — у главі Pull Request і стратегії гілкування.

    Типові помилки

    • «Лог показує ***, отже секрет у безпеці». А насправді трейс, скриншот, HAR чи JUnit-звіт містять його у відкритому вигляді — маскування діє лише на текст логів, не на артефакти.
    • «Видалив .env наступним комітом — прибрав секрет». А насправді він назавжди лишився в попередньому коміті історії; секрет скомпрометований і потребує ротації, а не лише видалення файлу.
    • «Маскування сховає будь-яку згадку секрета». А насправді маскується лише точний літерал — base64, склеєне чи посимвольне значення проходить у лог вільно.
    • «Додав файл у .gitignore — тепер він не відстежується». А насправді якщо файл уже під контролем версій, .gitignore на нього не діє: його треба спершу прибрати з індексу.
    • «Тест на форк-PR упав, бо не підхопився секрет — це баг CI». А насправді форк-PR навмисне позбавлений секретів, і protected-змінна недоступна на незахищеній гілці — це дизайн безпеки, а не поломка.
    • «Поклав пароль у playwright.config.ts для зручності». А насправді конфіг лежить у репозиторії — отже, пароль тепер у репозиторії й у його історії.

    Підсумок

    • Секрет — не змінна: він write-only в UI, шифрується at rest і маскується в логах; звичайна конфігурація (base URL, браузер, таймаути) — ні. Критерій: чи не шкідливо побачити це значення в публічному баг-репорті.
    • Маскування — best-effort пошук точного літерала й діє лише на текст логів. Похідні значення (base64, склеєні) і артефакти (трейс, скриншот, HAR) воно не рятує.
    • Витік = компрометація. Перша дія — ротація (відкликати старий, випустити новий); чистка git-історії — вторинна й старого токена безпечним не робить.
    • Креденшели тестових юзерів — теж секрети: з оточення, не з коду; ніколи не реальні акаунти клієнтів; storageState і .env не комітяться.
    • Форк-PR не бачить секретів за задумом (модель довіри до чужого коду); pull_request_target із чекаутом коду PR — класична діра.

    Що питають на співбесіді

    • «Чим змінна відрізняється від секрета в CI?» Дивляться, чи розумієте шифрування at rest, write-only й маскування, а не переказуєте «секрет — це щось важливе».
    • «Секрет випадково закомітили. Ваші дії?» Очікують «негайно ротувати», і лише потім — чистку історії. Відповідь виключно про git rebase без ротації провалює питання.
    • «Лог показує *** — секрет точно захищений?» Перевіряють, чи згадаєте межі маскування: похідні значення й артефакти.
    • «Чому автотести з пул-реквесту від форка не мають доступу до секретів?» Питання на модель довіри; сильний кандидат згадає й пастку pull_request_target.
    • «Як передаєте креденшели тестового користувача в CI?» Хочуть почути «через секрет-сховище в env», а не «зашиті в конфіг чи в код».

    Джерела

    Силабус ISTQB CTFL 4.0 торкається CI/CD і DevOps лише оглядово; гігієна секретів походить із практики DevSecOps, тож канонічні джерела — специфікації й документація інструментів вище.