Секрети та конфігурація в 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 і почищу історію», не назвавши ротацію, — червоний прапорець.
Профілактика дешевша за розбір інциденту. Хостинги вміють сканувати коміти на секрети (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 у джобі, який має секрети.
Практичний висновок для 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», а не «зашиті в конфіг чи в код».
Джерела
- Using secrets in GitHub Actions — сховище секретів, маскування,
add-mask, поведінка на форк-PR. - Secure use reference (GitHub Actions) — недовірений код, ризики
pull_request_target(pwn requests). - GitLab CI/CD variables — masked і protected змінні, доступ лише на захищених гілках.
- The Twelve-Factor App: Config — принцип «конфігурація в оточенні, а не в коді».
- OWASP Secrets Management Cheat Sheet — практики зберігання, ротації й виявлення секретів.
Силабус ISTQB CTFL 4.0 торкається CI/CD і DevOps лише оглядово; гігієна секретів походить із практики DevSecOps, тож канонічні джерела — специфікації й документація інструментів вище.
Чим у CI секрет відрізняється від звичайної змінної?
Змінна (variable) — це несекретна конфігурація: базовий URL стенду, назва браузера, таймаут, прапорець фічі, номер збірки. Її не соромно показати будь-кому. Секрет (secret) — значення, чиє розкриття шкодить: пароль, токен доступу, приватний ключ, рядок підключення до БД. Технічно секрет має три властивості, яких немає у змінної: він зазвичай write-only в інтерфейсі (після збереження не читається назад, лише перезаписується), шифрується at rest у сховищі й маскується в логах. У GitHub Actions це навіть різні контексти — vars для конфігурації та secrets для секретів; у GitLab у змінної є прапорці Masked і Protected; у Jenkins секрети живуть в окремому Credentials-сторі. Тому «прокинути env-змінну» годиться для конфігурації, а для креденшелів потрібне саме секрет-сховище.
Як швидко зрозуміти, значення перед вами секрет чи змінна?
Практичний тест: уявіть, що вставляєте це значення у публічний баг-репорт. Якщо від цього нікому не стане гірше — це змінна (URL, номер збірки, таймаут). Якщо вставити страшно — це секрет, і поводитися з ним треба інакше: тільки через сховище, ніколи в коді чи логах. Критерій зручний тим, що не вимагає пам'ятати класифікацію конкретної CI-системи — він працює на здоровому глузді. Помилка новачка — тримати токен «просто як env-змінну, бо так швидше»: тоді значення видно в UI і воно їде в лог відкритим текстом.
У коді тесту немає самого пароля — звідки він там береться під час прогону?
Значення один раз кладуть у секрет-сховище CI, а під час прогону система інжектить його в потрібний джоб — зазвичай як змінну середовища або тимчасовий файл, що зникає після джоба. Код читає його через process.env, тому сам репозиторій ніколи не містить значення. Наприклад, заголовок Authorization: Bearer ${process.env.API_TOKEN} збирається в рантаймі, а в git лежить лише посилання на змінну оточення. Це і є суть підходу «конфігурація в оточенні, а не в коді»: секрет живе поза кодовою базою й підставляється тільки в момент запуску.
Що таке маскування секретів у логах і чи можна на нього покладатися?
Маскування — це коли CI, помітивши в потоці логів точну послідовність символів секрета, замінює її на ***. Ключове слово тут «точну»: це best-effort пошук конкретного літерала, а не захист самого значення. Система порівнює рядки, тож будь-яка трансформація значення проходить повз фільтр, бо в логах уже інший рядок. Тому маскування — остання сітка безпеки на випадок випадкового console.log, а не дозвіл виводити секрети. Розраховувати, що воно «сховає будь-яку згадку секрета», — небезпечна ілюзія.
Чому вивід токена через base64 зливає його попри ввімкнене маскування?
Бо маскування шукає в логах саме той рядок, що лежить у сховищі, а Buffer.from(token).toString('base64') дає інакшу послідовність символів. Для фільтра це чужий текст — він його не впізнає й пропускає у відкритий лог. Той самий ефект дасть секрет, розбитий на дві половини, виведений посимвольно чи вшитий у query-рядок. Практичний наслідок: не можна вважати, що «раз є маскування, будь-який лог безпечний»; небезпечні саме похідні значення. На співбесіді цей приклад — маркер, що кандидат розуміє механізм, а не вірить у магію.
Секрет випадково закомітили в репозиторій. Ваші дії?
Насамперед виходимо з того, що витік = компрометація: секрет, який хоч на мить став доступним, вважається скомпрометованим, і «розкомпрометувати» його не можна. Тому перша й обов'язкова дія — ротація: відкликати старий секрет і випустити новий, після чого оновити значення у сховищі CI. І лише потім — вторинна гігієна: почистити git-історію через git filter-repo або BFG із подальшим force-push, щоб прибрати значення з клонів. Порядок принциповий: чистка історії старий токен безпечним не робить — поки його не відкликано, він валідний і в чужих клонах, і в кешах хостингу. Відповідь виключно про «зроблю rebase і почищу історію» без згадки ротації провалює це питання.
Чому додати .env у .gitignore після того, як його вже закомітили, не рятує?
.gitignore діє лише на невідстежувані файли: якщо файл уже під контролем версій, запис у ігнор на нього не впливає — git продовжує його відстежувати. Щоб ігнор запрацював, файл треба спершу прибрати з індексу командою git rm --cached. Але навіть це не прибирає значення з історії: секрет лишається в тому коміті, де його додали, і дістається одним git show. Тобто «додав у .gitignore» і «видалив наступним комітом» однаково створюють хибне відчуття безпеки. Реальний захист — ротація плюс переписування історії, а не сам ігнор.
Які дві пастки .gitignore ловлять навіть досвідчених?
Перша: ігнор не діє на вже відстежувані файли — додати .env у .gitignore після коміту марно, доки не приберете його з індексу. Друга, важливіша: git зберігає повну історію, тож секрет, «видалений» наступним комітом, нікуди не подівся — він лежить у попередньому коміті й доступний будь-кому з клоном. Обидві пастки об'єднує одна ілюзія: файлу візуально немає, отже проблему розв'язано. Насправді для секрета це означає лише одне — його треба ротувати, а історію переписувати окремим інструментом.
Ви отримали короткоживучий токен прямо під час прогону. Він замаскується автоматично?
Ні. Автоматично маскуються тільки ті значення, що лежать у секрет-сховищі; токен, обчислений у рантаймі (наприклад, обміняний на короткий термін життя), система «не знає», тож у лог він піде відкритим. У GitHub Actions його треба явно зареєструвати командою add-mask, після чого маскування почне його ловити. Це типова діра: команда впевнена, що «всі секрети маскуються», а динамічно отримані токени лишаються незахищеними. Тому будь-яке значення, обчислене під час джоба, потрібно свідомо додавати в маскування.
Лог показує ***. Секрет точно в безпеці?
Ні — маскування діє лише на текст логів, але не на артефакти прогону. Секрет, що потрапив у Playwright-трейс, у скриншот сторінки, у HAR-файл із тілами запитів чи в JUnit-звіт, зберігається там у відкритому вигляді, хоча консоль чесно показує ***. Артефакти складаються з файлів, які CI не сканує на літерали секретів, тож *** у логах про них нічого не каже. Звідси дисципліна: акуратно ставитися до того, що прикладаєте до фейлу, і не тягнути в трейс чутливі заголовки. «Лог чистий» і «секрет не витік» — різні твердження.
Як правильно передати креденшели тестового користувача в CI?
Так само, як будь-який інший секрет: покласти в секрет-сховище й читати з оточення через process.env, а не зашивати в код чи конфіг. Спокуса «це ж просто тестовий юзер, хай пароль лежить у коді» помилкова — конфіг у репозиторії означає креди в репозиторії й у його історії. Корисно додати раннє падіння (fail fast): якщо TEST_USER чи TEST_PASSWORD не задано, тест має одразу впасти з чіткою помилкою про конфігурацію, а не давати туманний фейл усередині логіну. Так відразу видно, що зламався прогін, а не застосунок.
Чому storageState вважають секретом, а не просто кеш-файлом?
Бо storageState зберігає сесійні cookie й токени після логіну — тобто це живий доступ до застосунку. Маючи цей файл, можна зайти під тим користувачем без пароля, рівно як із валідним токеном. Тому його не комітять: додають у .gitignore і генерують наново на кожному прогоні, а не тримають у репозиторії. Практика «залогінитися раз і перевикористати стан» зручна, але не скасовує того, що результат — повноцінний секрет, який теж має бути легко ротувати на випадок витоку через артефакт чи лог.
Чому покласти пароль у playwright.config.ts — погана ідея, навіть «для зручності»?
Тому що конфіг лежить у репозиторії, а отже пароль тепер теж у репозиторії — і в усій його історії. Те, що значення в «конфізі», а не в тесті, нічого не змінює: git не розрізняє, у якому файлі текст. Далі його побачить кожен із доступом до репо, він потрапить у форки й клони, і навіть видалення наступним комітом не прибере його з історії. Правильно — тримати значення в сховищі й підставляти через process.env, лишаючи в конфізі лише посилання на змінну оточення.
Що означає принцип 12-factor «конфігурація в оточенні, а не в коді» для автотестів?
Він каже, що все, що змінюється між середовищами — базовий URL, вибір браузера, таймаути, набір фіч-прапорців і, окремо, секрети — має братися з оточення, а не хардкодитися. Несекретну частину можна тримати в репозиторії (наприклад, окремими файлами під середовища або через projects у конфізі Playwright), бо її не страшно показати. А секрети розкладаються по середовищах у сховищі й ключуються середовищем. Наслідок: один і той самий код тестів бігає проти локалі, staging чи смоуку на проді, змінюючи лише оточення, а не код.
Чому прод-секрет не має бути досяжним із пайплайна, що ганяє staging чи PR, і як це забезпечують?
Головний інваріант: чим ширше коло пайплайнів бачить прод-креди, тим більша поверхня для витоку — випадкового чи зловмисного. Тому прод і staging — це різні секрети з різними доступами, а не один набір «на всі середовища». У GitHub Actions для цього є Environments із власними секретами й правилами захисту: джоб, що деплоїть у прод, можна поставити за обов'язковим апрувом рев'юера, перш ніж він дотягнеться до прод-секретів. У GitLab захищені (protected) змінні віддаються лише пайплайнам на захищених гілках і тегах, тож випадкова фіча-гілка до них не дотягнеться. Окремо варто пам'ятати, що секрети не запікаються в Docker-образ (образ віддається багатьом), а інжектяться в контейнер під час запуску.
Чому автотести з пул-реквесту від форка навмисне не мають доступу до секретів?
Бо код у форк-PR недовірений: його написала невідома людина, і він виконається у вашому пайплайні. Якби такий прогін мав секрети, зловмиснику вистачило б додати в PR один крок, що виводить токен у лог (чи, як ми бачили, у base64) — і секрет витік би при першому ж прогоні. Тому в GitHub Actions подія pull_request із форка не отримує секретів узагалі, а вбудований GITHUB_TOKEN стає read-only; для нових контриб'юторів ще й потрібен ручний апрув на запуск воркфлоу. GitLab діє так само: гілка форку не захищена, тож protected-змінні їй недоступні. Це не поломка конфігурації, а навмисна модель довіри — і кандидат, який називає це «багом CI», показує нерозуміння.
Що таке pull_request_target і чому це класична діра безпеки?
pull_request_target — подія, що запускається в контексті базового репозиторію й тому має доступ до секретів; за замовчуванням вона чекаутить базову гілку, а не код із PR, тож сама по собі безпечна. Небезпека виникає, коли воркфлоу явно робить чекаут коду пул-реквесту (head.sha) і потім виконує його скрипти складання чи тестів: тоді недовірений код біжить із повним доступом до секретів. Це відома атака під назвою pwn request: зловмисник шле PR, чий код зливає токени, а вразливий воркфлоу сам його запускає з секретами. Правило просте: не виконуйте код із PR у джобі, який має секрети. Згадка цієї пастки на співбесіді відрізняє сильного кандидата від того, хто знає лише «форки секретів не бачать».
Як розкласти перевірки CI за рівнем довіри до коду з форк-PR?
Ідея — розділити джоби за тим, чи потрібні їм секрети. Те, що секретів не потребує — лінт, перевірка типів, юніти, збірка — спокійно бігає прямо на форк-PR і дає контриб'ютору швидкий фідбек, нічим не ризикуючи. Те, що потребує кредів — e2e проти реального стенду — запускається лише на довіреній основі: після мержу, за апрувом мейнтейнера чи за спеціальною міткою. Так зберігається і зручність для зовнішніх контриб'юторів, і безпека секретів. Спроба «просто дати форк-PR секрети, щоб e2e працювали» — саме те, від чого захищає модель довіри.
Три робочі ситуації довкола секретів у CI: розбір інциденту «токен закомітили» з правильним порядком дій, передача кредів тестового юзера через оточення з демонстрацією меж маскування, і розкладка джобів форк-PR за довірою з пасткою pull_request_target. Скрізь — що робити і чому саме так.
Кейс 1. Секрет закомітили: розбір інциденту крок за кроком
Типова ситуація: розробник додав файл .env із реальним API_TOKEN, штовхнув гілку, за пів години помітив і наступним комітом файл видалив. У пул-реквесті .env більше немає — здається, проблему закрито. Насправді ні.
Що сталося насправді:
- Токен уже в git-історії. Коміт, де його додали, нікуди не зник; будь-хто з доступом до репо дістане значення одним
git show <sha>:.env. - Якщо репозиторій публічний або вже спрацювало дзеркалювання/CI, значення могло за секунди потрапити в логи, кеші хостингу й чужі клони.
- Видалення файлу наступним комітом прибирає його лише з останнього стану дерева, але не з історії — це і є хибне відчуття безпеки.
Правильна послідовність дій:
- Вважати токен скомпрометованим. Він хоч на мить був доступний — цього достатньо.
- Ротація насамперед: відкликати старий токен у провайдера й випустити новий. Поки старий валідний, будь-яка чистка історії безсенсова.
- Оновити значення у секрет-сховищі CI, перекласти його з
.envуsecrets/Credentials. - Почистити історію:
git filter-repoабо BFG прибирає значення з усіх комітів, далі force-push і оновлення захищених гілок. Це вторинна гігієна — вона чистить клони, але старий токен безпечним не робить (його вже відкликано на кроці 2). - Увімкнути захист на майбутнє: secret scanning і push protection на хостингу плюс pre-commit хук з gitleaks локально.
Червоний прапорець на співбесіді — відповідь «зроблю rebase і почищу історію» без згадки ротації. Вона розв'язує другорядне (сліди в історії) і лишає головне — не відкликаний доступ. Токен, який ви «прибрали» з git, але не відкликали, працює далі.
Кейс 2. Креди тестового юзера через оточення — і межі маскування
Секрет приходить у тест лише з оточення, а код одразу падає, якщо його не задано:
import { test, expect } from '@playwright/test';
const user = process.env.TEST_USER;
const password = process.env.TEST_PASSWORD;
// fail fast: без кредів немає сенсу навіть відкривати логін
if (!user || !password) {
throw new Error('TEST_USER / TEST_PASSWORD не задано — перевірте секрети CI');
}
test('логін тестового юзера', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(user);
await page.getByLabel('Пароль').fill(password);
await page.getByRole('button', { name: 'Увійти' }).click();
await expect(page.getByTestId('user-menu')).toBeVisible();
});
Раннє падіння з чіткою помилкою одразу вказує на проблему конфігурації прогону, а не на баг застосунку — інакше тест дав би туманний фейл десь усередині логіну.
Тепер найтонше: навіть із секретом у сховищі його легко злити самому. Маскування ловить лише точний літерал у тексті логів — ось що воно рятує, а що ні:
| Дія в тесті | Що потрапляє в лог | Замасковано? |
|---|---|---|
console.log(process.env.TEST_PASSWORD) | *** | так, точний літерал |
console.log(Buffer.from(password).toString('base64')) | base64-рядок | ні, інша послідовність |
Пароль у page.goto через query-рядок | URL зі значенням | ні, похідний рядок |
| Пароль у Playwright-трейсі чи скриншоті | значення всередині файлу | ні, це не текст логу |
| Короткий токен, обміняний у рантаймі | значення | ні, доки не викликано add-mask |
Висновок: *** у консолі означає лише, що спрацював пошук літерала. Похідні значення (base64, склеєне, у складі URL) і будь-які артефакти — трейс, HAR, JUnit — маскування не бачить. Тому чутливі заголовки не тягнуть у трейс, а storageState тримають у .gitignore і генерують на кожному прогоні: це живий токен, з яким заходять без пароля.
Кейс 3. Форк-PR: що бігає прямо, а що чекає на довіру
Публічний проєкт приймає PR від будь-кого, і код такого PR недовірений. Модель довіри GitHub Actions проста: подія pull_request із форка не отримує секретів, а GITHUB_TOKEN стає read-only. Тож перевірки розкладають за тим, чи потрібні їм креди:
| Джоб | Потрібні секрети? | Де запускати |
|---|---|---|
| Лінт, перевірка типів | ні | прямо на форк-PR |
| Юніт-тести, збірка | ні | прямо на форк-PR |
| e2e проти стенду з логіном | так | після мержу або за апрувом мейнтейнера |
| Деплой у прод | так, прод-креди | Environment з обов'язковим апрувом |
Такий розподіл дає зовнішньому контриб'ютору швидкий фідбек від дешевих перевірок, а секрети лишає для довіреної основи.
Окрема пастка — подія pull_request_target. На відміну від pull_request, вона біжить у контексті базового репо й має секрети, тому нею часто намагаються «полагодити» відсутність кредів на форк-PR. Небезпека — у комбінації з явним чекаутом коду PR:
# АНТИПРИКЛАД: недовірений код біжить із доступом до секретів
on: pull_request_target
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # код із PR
- run: npm ci && npm test # виконуємо його — а секрети поруч
Це і є pwn request: зловмисник надсилає PR, чий білд-скрипт зливає токен, а воркфлоу сам його запускає з повним доступом до секретів. Правило, яке це закриває: не виконуйте код із пул-реквесту в джобі, що має секрети. Якщо pull_request_target потрібен лише щоб лишити коментар чи мітку — не робіть у ньому чекаут і запуск коду PR.
Змінна vs секрет
- Розумію різницю змінна vs секрет і три технічні властивості секрета (write-only в UI, шифрування at rest, маскування в логах), яких у змінної немає: змінна — несекретна конфігурація (URL, браузер, таймаут), секрет — пароль/токен/ключ, чиє розкриття шкодить.
- Знаю практичний критерій: чи не шкідливо вставити це значення у публічний баг-репорт.
- Знаю, що в різних системах це різні сутності: контексти
varsіsecretsу GitHub Actions, прапорці Masked/Protected у GitLab, Credentials-стор у Jenkins.
Маскування і його межі
- Розумію, що маскування — це best-effort пошук точного літерала в логах, а не захист самого значення.
- Можу пояснити, чому base64, склеєне з частин чи посимвольне значення проходить повз маскування.
- Знаю, що обчислений у рантаймі токен не маскується автоматично й у GitHub Actions його реєструють через
add-mask. - Розумію, що маскування діє лише на текст логів, а не на артефакти: трейс, скриншот, HAR і JUnit-звіт містять секрет відкритим.
Витік і ротація
- Знаю короткий список того, що не комітять: паролі, токени, приватні ключі й сертифікати, рядки підключення до БД, ключі хмар,
.envіз реальними значеннями,storageState. - Розумію головне правило: витік = компрометація, перша дія — ротація; чистка git-історії (
filter-repo/BFG + force-push) вторинна й старого токена безпечним не робить. - Знаю дві пастки
.gitignore: не діє на вже відстежувані файли й не прибирає значення з історії. - Знаю профілактику: secret scanning і push protection на хостингу, pre-commit хук з gitleaks чи detect-secrets локально.
Креденшели тестових юзерів
- Розумію, що креди тестових акаунтів — теж секрети: з оточення через
process.env, не з коду чи конфіга. - Можу пояснити користь fail fast: раннє падіння з чіткою помилкою, коли
TEST_USER/TEST_PASSWORDне задано. - Знаю, чому
storageState— живий секрет (сесійні cookie й токени), який не комітять, а генерують на кожному прогоні. - Розумію, чому автотести не ходять під реальними акаунтами клієнтів, а під виділеними сідованими юзерами з мінімальними правами.
Середовища, Docker і форк-PR
- Знаю принцип 12-factor: конфігурація в оточенні, а не в коді; несекретне можна тримати в репо, секрети — у сховищі, ключовані середовищем.
- Розумію інваріант: прод-секрет недосяжний із пайплайна staging/PR; забезпечують Environments з апрувом (GitHub) і protected-змінні на захищених гілках/тегах (GitLab).
- Знаю, що секрети не запікаються в Docker-образ, а інжектяться в контейнер під час запуску.
- Можу пояснити, чому форк-PR не бачить секретів (код недовірений,
pull_requestбез секретів,GITHUB_TOKENread-only), і пасткуpull_request_target: чекаут коду PR (head.sha) з його виконанням — це pwn request. - Розумію принцип розкладки за довірою: lint/typecheck/unit/build на форк-PR, e2e з кредами — після мержу чи за апрувом мейнтейнера.
Чим секрет у CI відрізняється від звичайної змінної?
Питання
Змінна (variable) vs секрет (secret) у CI — у чому різниця?