✻ Урок 3.4 · Тема 3: Git и CI
Качество и безопасность в CI: сканеры, секреты, OIDC
Содержание урока
Зачем это нужно
Зелёный CI из прошлого урока говорит только «код запускается и тесты проходят». Он не заметит токен (длинную секретную строку-пропуск, по которой программа получает доступ к сервису без ввода пароля человеком), случайно закоммиченный в файл. Не заметит старую библиотеку (чужой готовый код, который ты подключил к проекту) с известной дырой: уязвимостью (vulnerability), то есть ошибкой в коде, которой может воспользоваться злоумышленник. Подробнее об уязвимостях и их учёте в курсе будет в уроке 10.5. И не заметит, что сам workflow (файл с описанием проверок) выдаёт чужому коду больше прав, чем нужно. При этом серверы, на которых крутится CI, стали любимой целью атак: у них есть доступ к секретам и к продакшену (production: «боевой» сервер, на котором работает приложение для настоящих пользователей, в отличие от тестового стенда; ошибка там стоит денег и нервов).
На работе это выглядит так. Токен, случайно попавший в публичный репозиторий, находят автоматические боты за минуты, а не за дни. Отчёт об уязвимостях приходит от безопасников (сотрудников, которые отвечают за защиту компании) с дедлайном (крайним сроком): «закрыть до пятницы». Правильный ответ индустрии называется shift-left («сдвиг влево»: представь линию времени от написания кода до боевого сервера, проверки двигают к её левому краю): их переносят как можно раньше, на Pull Request (запрос на слияние ветки с кодом в main, урок 3.2), пока ошибку исправить дёшево. Найти токен до слияния стоит одну строку в комментарии. Найти его в продакшене стоит бессонной ночи, смены всех ключей и разговора с руководством.
Шаг проекта: в ~/notes в ci.yml появляются jobs (независимые задачи workflow, каждая на своей чистой машине) secrets и trivy-fs. TruffleHog (тру́фл-хог, «свинья-трюфельщик») это программа, которая ищет в коде и истории секреты, как свинья ищет трюфели. Trivy (три́ви) это программа, которая сверяет твои библиотеки с базой известных дыр. Оба разберём в теории. У Makefile (файл с короткими именами для длинных команд, урок 1.6) цель lint становится настоящей (проверка кода линтером ruff из урока 3.3), и добавляется цель scan для тех же проверок на своём компьютере. Рядом появляется .github/dependabot.yml: настройки Dependabot (бота GitHub, который сам предлагает обновить устаревшие библиотеки; разберём ниже). А ветка main начинает требовать пять зелёных проверок: без них слияние не пройдёт.
Что нужно знать
- Урок 1.6: bash и Make: в
Makefile«Заметок» цельlintпока была скелетом изpy_compile, здесь она станет настоящей; там же коды выхода команд и переменные окружения. - Урок 1.2: текст и пайпы:
|,cut,trи$(...)понадобятся, чтобы разобрать токен. - Урок 2.6: TLS и HTTPS: подпись, срок жизни и проверка доверия; тот же принцип у токенов OIDC (OpenID Connect: способ войти в облако по подписанному одноразовому документу вместо вечного ключа; разберём в конце теории).
- Урок 3.1: коммиты и история: секрет, попавший в коммит, живёт в истории.
- Урок 3.2: remotes, rebase, Pull Request: ветки, PR, защита
main. - Урок 3.3: CI в GitHub Actions: workflow (файл
.github/workflows/*.ymlс описанием проверок), job (задача внутри workflow), step (шаг внутри job),permissions(права токена запуска), matrix (запуск одной job на нескольких версиях),ci.yml,requirements-dev.txtиruff.toml. Этот урок дописывает тот самыйci.yml.
Картина целиком
Представь завод. У проходной охрана смотрит, что несут внутрь и что выносят. Это проверка на секреты: не выносит ли кто-то ключи от склада. Склад принимает сырьё от поставщиков, и у каждой партии есть паспорт качества. Это проверка зависимостей: не приехал ли в партии брак, о котором уже объявлено. Сотрудникам выдают пропуска только в нужные цеха. Это права токена. Подрядчиков с улицы пускают по списку и сверяют документы. Это сторонние actions. А при въезде на закрытую территорию склада вместо вечного ключа выдают одноразовый пропуск на смену. Это OIDC.
Вот путь изменения через все эти рубежи:
flowchart TD
C["твой коммит"] --> PR["Pull Request"]
PR --> R["GitHub запускает workflow<br>на чистой машине (runner)"]
R --> L["lint<br>стиль, ошибки"]
R --> T["test<br>поведение"]
R --> S["secrets<br>токены в истории"]
R --> V["trivy-fs<br>CVE в библиотеках"]
L --> G{"все зелёные?"}
T --> G
S --> G
V --> G
G -->|да| OK["можно вливать в main"]
G -->|нет| NO["слияние заблокировано"]
Права токена на всём запуске ограничены: contents: read.
Дальше по порядку: какие вообще бывают угрозы для CI, чем секрет отличается от обычных данных, как секреты утекают и как их ловит TruffleHog, как читать отчёт об уязвимостях Trivy, что такое права GITHUB_TOKEN (временный пропуск, который GitHub сам выдаёт каждому запуску workflow), как чужие данные превращаются в команды (script injection, «внедрение скрипта»: атака, когда текст, введённый посторонним, выполняется как твоя команда), чем опасны сторонние actions (готовые чужие шаги, которые ты подключаешь строкой uses:) и как с этим помогает Dependabot, как OIDC (способ войти в облако по подписанному одноразовому документу вместо вечного ключа) заменяет хранимые ключи временными и как все рубежи складываются в защиту main.
Теория
Секрет, токен, ключ, пароль: что именно мы защищаем
Слово «секрет» в этом уроке встречается на каждой странице. Если не понимать, что оно значит и чем секрет отличается от обычных данных, непонятно, зачем вообще нужны сканеры и почему утечка так дорого стоит.
Обычные данные похожи на адрес твоего дома: его знают почтальон и соседи, и ничего страшного. Секрет похож на ключ от двери: кто им владеет, тот входит, и дверь не спрашивает, кто ты. Дверь проверяет не человека, а предъявленный ключ. Аналогия ломается в одном: ключ от двери можно физически отобрать и заметить пропажу, а скопированный секрет продолжает лежать у тебя в руках, и ты не узнаешь, что копия уже у кого-то ещё.
Программы, в отличие от людей, не вводят пароль руками с клавиатуры. Когда CI надо отправить образ в реестр, зайти в облако или запросить API, программа предъявляет заранее выданную строку. Её называют по-разному, и названия стоит различать:
- Пароль (password): строка, которую человек помнит и вводит. Для программ обычно не подходит.
- Токен (token, «жетон»): длинная случайная строка, которую выдаёт сервис. Её предъявляют вместо пароля. У токена бывают ограниченные права и срок действия. Пример:
ghp_...токен GitHub. - Ключ доступа (access key): пара из идентификатора и секретной части, так устроено облако (например, ключи AWS). Идентификатор похож на логин, секретная часть на пароль.
- Секрет (secret): общее название для всего этого. Любая строка, знание которой само по себе даёт доступ.
| Обычные данные | Секрет |
|---|---|
| имя репозитория | токен GitHub (ghp_...) |
| версия Python | ключ облака (AKIA... и секретная часть) |
| адрес сервера | пароль от базы (строка в .env) |
| Если утекло: неприятно, но не опасно | Если утекло: любой, кто прочитал, действует от твоего имени |
Главное свойство секрета: владение равно доступу. Сервис не знает, что это «не ты». Поэтому, если секрет попал в чужие руки, единственный надёжный ответ это ротация (rotation): отозвать старый секрет в сервисе, который его выдал, и выпустить новый. Удалить строку из файла мало: копия уже прочитана.
Секреты хранят в специальных местах. В GitHub это Settings, Secrets and variables, Actions: значение вводится один раз, потом оно доступно workflow как ${{ secrets.ИМЯ }}, а в логах заменяется звёздочками (маскируется). Прочитать значение обратно через интерфейс нельзя, только перезаписать. В коде и в репозитории секретам не место.
Возьмём строку из файла config_local.py: GITHUB_TOKEN = "ghp_fZgk...KOHO". Префикс ghp_ говорит, что это личный токен GitHub (personal access token), дальше 36 случайных символов. Что может сделать тот, кто её прочитал? Всё, что разрешено токену: читать приватные репозитории владельца, писать в них, если права такие. GitHub не спросит «а ты точно владелец?». Поэтому строка в публичном репозитории это не «небрежность в коде», а выданный всем желающим ключ.
Прикинь сам: имя репозитория и токен
ghp_...попали в публичный лог. Что из этого опаснее и почему?
Токен: тот, кто его прочитал, действует от твоего имени. Имя репозитория видно всем и никому ничего не даёт.
Осторожно: «Токен в приватном репозитории безопасен». Приватный репозиторий читают все участники, его клонируют на ноутбуки, а завтра его могут сделать публичным или скопировать в форк. Секреты не хранят в репозитории вообще, даже в приватном. Второе заблуждение: «.env в .gitignore и всё хорошо». .gitignore (список файлов, которые git не замечает, урок 3.1) действует только на ещё не добавленные файлы. Если файл уже был в коммите, запись в .gitignore его из истории не уберёт.
Главное: секрет это любая строка, знание которой само по себе даёт доступ, и в CI с ним работают программы, а не люди.
Проверь понимание: в лог CI случайно попал токен. Достаточно ли удалить строку
echoиз workflow?
Ответ
Нет. Лог уже записан, и любой, кто имел к нему доступ, мог прочитать токен. Нужна ротация: отозвать токен в сервисе и выпустить новый. Удаление echo только не даёт утечке повториться.
Мы знаем, что защищать. Теперь вопрос: от кого и через что? Нужна модель угроз.
Модель угроз для CI: что и от чего защищаем
Инструмент защиты нельзя выбрать, пока не знаешь, от чего защищаешься. Без ясной картины команда ставит десять сканеров, часть из них шумит, часть дублирует друг друга, а главные дыры остаются открытыми.
CI-runner (машина, на которой GitHub выполняет твой workflow) похож на временного сотрудника, который каждый раз приходит в офис, получает ключи от сейфа с секретами и делает то, что написано в инструкции. Инструкцию (workflow) пишут разные люди, в том числе авторы Pull Request. Аналогия ломается в одном месте: живой сотрудник заметил бы странную инструкцию, а runner выполнит любую.
Три вещи попадают на runner, и каждая может оказаться опасной:
- Секреты (secrets): токены, пароли, ключи. Они нужны CI, чтобы, например, отправить образ в реестр. Риск: секрет утёк в репозиторий, в лог или в результат сборки.
- Чужой код в зависимостях. Приложение почти никогда не пишут целиком с нуля: подключают библиотеки. Если в библиотеке дыра или её подменили, дыра переезжает в твой проект. Это называют supply chain (цепочка поставки): путь, которым чужой код доходит до тебя.
- Сам workflow. Его текст выполняется как программа. Если в него можно подсунуть чужие данные так, что они станут командами, runner будет выполнять чужие команды.
Для каждого риска есть свой класс инструментов:
| Класс | Что ищет | Пример | Когда запускать |
|---|---|---|---|
| Secret scanning (поиск секретов) | ключи, токены, пароли в коде и истории | TruffleHog, gitleaks | на каждый PR |
| SCA (Software Composition Analysis, анализ состава ПО) | известные уязвимости в чужих библиотеках | Trivy, Dependabot | на PR и по расписанию |
| SAST (Static Application Security Testing) | опасные конструкции в твоём коде без запуска | Semgrep, CodeQL | на каждый PR |
| DAST (Dynamic Application Security Testing) | дыры в работающем сервисе, атака «снаружи» | OWASP ZAP | на стенде |
В курсе ставим первые два класса, потому что они дают больше всего пользы за минимум настройки. SAST и DAST достаточно уметь назвать и различать: SAST читает код, DAST стучится в запущенный сервис.
Есть ещё локальный уровень: pre-commit (программа, которая запускает проверки перед каждым коммитом на твоём компьютере). Он полезен, но его можно обойти флагом git commit --no-verify, а на чужом компьютере он вообще может быть не установлен. Поэтому решающая проверка всегда в CI, где её нельзя пропустить. Локальные проверки экономят время, а не гарантируют безопасность.
Возьмём «Заметки». Секреты: пока их нет, но к уроку 6.3 появится ключ облака. Зависимости: сегодня requirements.txt пустой, потому что приложение работает на стандартной библиотеке, но из v5 добавятся prometheus_client и OpenTelemetry. Workflow: у нас уже есть ci.yml, и любой PR запускает его. То есть все три поверхности атаки у нас есть или скоро появятся.
Прикинь сам: какие три вещи попадают на runner и могут оказаться опасными?
Код из PR, сторонние действия и секреты с токеном. Чужой код запускается там, где лежат ключи, и это главный риск.
Тут часто путают так. «Мы маленький проект, нас никто не атакует». Боты не выбирают жертву: они сканируют весь публичный GitHub подряд, а вредоносные пакеты подсовывают тем, кто их случайно поставит. Размер проекта не защищает.
Главное: инструмент защиты выбирают под конкретную угрозу, а в CI опасны чужой код, чужие действия и доступные им секреты.
Проверь понимание: чем SCA отличается от SAST и какой из них найдёт известную дыру в библиотеке
requests?
Ответ
SCA смотрит на чужой код (зависимости) и сверяет их версии с базой известных уязвимостей. SAST разбирает твой собственный код. Дыру в requests найдёт SCA (Trivy, Dependabot).
Самая частая угроза это утёкший секрет. Как именно он утекает?
Секреты: как они утекают и почему удаление не помогает
Самая частая ошибка новичка: увидеть токен в коммите, удалить его следующим коммитом и считать проблему решённой. Чтобы не сделать так, нужно понимать, как git хранит историю.
История git похожа на бухгалтерскую книгу, которую ведут шариковой ручкой: страницу нельзя вырвать, можно только дописать внизу «запись такая-то отменена». Кто открыл книгу на старой странице, прочитает старую запись. Аналогия ломается тем, что книгу можно переписать целиком (git filter-repo), но копии книги к этому моменту уже могли разойтись по чужим рукам.
Секрет утекает тремя путями.
- Коммит. Файл
.envили строкаTOKEN = "..."в коде попала в коммит. Коммит навсегда хранит полный снимок файлов на тот момент (урок 3.1). Следующий коммит с удалением создаёт новый снимок без токена, а старый остаётся в истории и доступен любому, у кого есть репозиторий. - Лог CI. Команда
echo $TOKENили режимset -x(печатать каждую команду перед выполнением) выводит секрет в открытый журнал запуска. GitHub маскирует значения, которые знает как секреты, но не всегда и не в любой форме (например, в закодированном виде маска не сработает). - Результат сборки. Секрет попал внутрь архива или образа, которые потом кто-то скачал.
flowchart LR
A["A<br>чисто"] --> B["B<br>добавили settings_local.py<br>с токеном"] --> C["C<br>удалили settings_local.py<br>токена в снимке нет"]
Что видит diff PR (сравнение начала и конца): ничего, файла нет ни там, ни там. Что видит git log -p: коммит B с токеном, целиком. Что видит TruffleHog: коммит B, потому что он читает каждый коммит, а не только итог.
Вот настоящий результат опыта из задания 2 (значения у тебя будут другие). Мы добавили файл с выдуманным токеном, удалили его следующим коммитом и посмотрели историю поиском по тексту токена:
$ git log -p -S'ghp_' --oneline
3979070 test: убрали токен
diff --git a/config_local.py b/config_local.py
deleted file mode 100644
...
-GITHUB_TOKEN = "ghp_fZgk...KOHO"
6ce4390 test: выдуманный токен
diff --git a/config_local.py b/config_local.py
new file mode 100644
...
+GITHUB_TOKEN = "ghp_fZgk...KOHO"
-S'ghp_' значит «покажи коммиты, в которых число вхождений строки изменилось». Оба коммита нашлись: и тот, где токен появился (строка с +), и тот, где исчез (строка с -). Токен читается в обоих. Разбор ключей команды будет в задании 2.
Отсюда порядок действий при утечке, который не меняется никогда:
- Отозвать секрет и выпустить новый (ротация, rotation). Это единственное, что реально закрывает дыру: старый ключ перестаёт работать.
- Посмотреть журнал использования старого секрета у провайдера: не пользовался ли им кто-то, кроме тебя.
- Убрать секрет из кода, читать его из окружения (переменной окружения, как
NOTES_DATAв уроке 1.6) или из хранилища секретов (разберём в уроке 9.2). - Только потом, если нужно, почистить историю (
git filter-repo), сделать force-push и попросить всех переклонировать репозиторий.
Чистка истории без первого шага бесполезна: пока ты чистил, репозиторий мог уже склонировать бот или коллега.
Прикинь сам: ты удалил файл с токеном следующим коммитом. Можно ли прочитать токен после этого?
Да: коммит с токеном остаётся в истории, его покажет git log -p. Удаление ничего не стирает, поэтому токен надо отозвать.
Осторожно: «Я удалил коммит и сделал git push --force, значит, секрета больше нет». Секрет мог быть скопирован, пока лежал в истории. Считай его скомпрометированным (утёкшим) с той секунды, как он попал на GitHub.
Главное: git хранит каждое состояние, поэтому удалённый секрет считается утёкшим и подлежит замене.
Проверь понимание: ты нашёл в PR закоммиченный токен и сразу сделал
git push --forceс удалением коммита. Что ещё обязательно нужно сделать и почему?
Ответ
Отозвать токен у провайдера и выпустить новый: пока он лежал в истории, его могли скопировать (боты, форки, кэши). Затем проверить журнал использования токена. Переписывание истории вторично.
Удаление не лечит, нужен сканер. Как TruffleHog находит секрет?
TruffleHog: как сканер находит секреты и что такое статусы
Если просто «искать похожие на пароли строки», получится тысяча ложных срабатываний, и команда перестанет смотреть на красный статус. Поэтому TruffleHog устроен хитрее: он ищет секрет и пробует понять, живой ли он.
Представь охранника, который нашёл на полу ключ. Он может: (1) сказать «это ключ» и всё; (2) сходить и проверить, открывает ли он реальный замок. Второе намного полезнее: ключ от давно снесённого склада не стоит тревоги. Аналогия ломается тем, что охранник проверяет замок на чужом складе, поэтому TruffleHog нужна сеть до сервиса провайдера.
TruffleHog работает в три шага.
- Обход. Он читает коммиты репозитория один за другим (все или заданный диапазон) и разбирает файлы в каждом.
- Детекторы (detectors). Для каждого провайдера (GitHub, AWS, Slack и сотни других) у него есть шаблон: как выглядит ключ. У токена GitHub форма
ghp_и 36 букв с цифрами. Строка подошла под шаблон, значит, это кандидат. - Проверка (verification). Для кандидата TruffleHog делает безобидный запрос к API провайдера: «этот токен действует?» Из ответа выводится статус:
| Статус | Что значит | Пример |
|---|---|---|
verified |
провайдер подтвердил, что секрет живой | настоящий действующий токен |
unverified |
форма подходит, но провайдер сказал «не знаю такого» | выдуманный или уже отозванный токен |
unknown |
проверить не удалось: нет сети, лимиты запросов, сбой | сервис провайдера недоступен |
Какие статусы считать проблемой, задаёт флаг --results (старое имя --only-verified соответствует режиму verified). Комбинации:
| Режим | Блокирует | Плюс | Минус |
|---|---|---|---|
verified |
только подтверждённо живые | почти нет шума | пропустит живой секрет, если проверка не сработала |
verified,unknown |
живые и «не смогли проверить» | отозванные не шумят, сбои сети не дают пропуск | выдуманные и старые ключи не увидит |
verified,unverified,unknown |
вообще всё похожее | не пропустит ничего | шумит на ложных срабатываниях (false positive) |
Мы прогнали все три режима на репозитории с выдуманным токеном ghp_... (настоящий вывод в задании 2). Результат:
режим verified -> ничего не найдено, код выхода 0
режим verified,unknown -> ничего не найдено, код выхода 0
режим verified,unverified,unknown -> Found unverified result, код выхода 183
Токен выдуманный, GitHub его не знает, статус unverified. Поэтому два «мягких» режима его пропустили, а строгий поймал. А если отключить сеть (в опыте мы направили запросы в никуда), статус превращается в unknown с пометкой Verification issue: connection refused, и уже режим verified,unknown тоже краснеет. Код выхода 183 придумал сам TruffleHog: ненулевой код при флаге --fail заставляет job стать красным (о кодах выхода: урок 1.6).
Что выбрать? В боевом репозитории обычно берут verified,unknown: живые ключи и непроверяемые блокируют PR, а старые и выдуманные (в тестах их полно) не мешают работе. В учебном проекте секретов настоящих нет, а тренировочные ключи выдуманные, поэтому мы включим строгий режим: иначе учебный «утёкший» токен никто не поймает и нечему будет учиться.
Что за диапазон он сканирует в PR. В GitHub Actions официальное действие TruffleHog для события pull_request берёт все коммиты от точки, где ветка отошла от main (base), до последнего коммита ветки (head). Оно вызывает trufflehog git ... --since-commit <base> --branch <head>. Чтобы эти коммиты вообще были на runner, actions/checkout должен скачать всю историю: параметр fetch-depth: 0. По умолчанию checkout скачивает только один последний коммит (shallow, «мелкий» клон), и всё, что было раньше, сканеру не видно. Я проверил это опытом (задание 2): в клоне с --depth 1 (один коммит) репозиторий с токеном в истории прошёл проверку с кодом 0, а в полном клоне тот же токен был пойман.
Прикинь сам: сканер нашёл токен, но проверка его не подтвердила (
unverified). Можно ли успокоиться?
Нельзя: unverified значит, что проверить не удалось (отозван, нет сети), но строка похожа на настоящий токен. Решение принимает человек.
Тут часто путают так. «Зелёный secrets значит, что секретов нет». Нет: он значит только «не нашёл в выбранных статусах». Режим verified и выдуманный или уже отозванный ключ дают зелёный статус при том, что ключ лежит в истории. Второе заблуждение: «сканер найдёт пароль любого вида». Он ищет по шаблонам известных провайдеров. Ключ AWS из документации (AKIAIOSFODNN7EXAMPLE) детектор знает как пример и намеренно пропускает: мы проверили это в задании 2, и он не нашёлся даже в строгом режиме.
Главное: TruffleHog читает каждый коммит и сверяет найденное с реальным сервисом:
verifiedнастоящий,unverifiedне подтверждён.
Проверь понимание: в PR добавили два коммита: в первом токен GitHub, во втором он удалён. Найдёт ли TruffleHog токен, если
fetch-depthне указан? А еслиfetch-depth: 0?
Ответ
Без fetch-depth: 0 runner скачивает только последний коммит, где токена уже нет, поэтому не найдёт. С fetch-depth: 0 доступны оба коммита, сканер читает каждый и найдёт токен в первом (при подходящем режиме статусов).
Секреты закрыты. Теперь другая тема: дыры в чужих библиотеках.
Уязвимости в зависимостях: CVE, серьёзность и Trivy
Ты подключил библиотеку и забыл о ней на год. За это время в ней нашли дыру, о которой написано в открытых базах. Никто в команде не читает новости обо всех библиотеках проекта, поэтому проверку поручают программе.
Это как список отзыва товаров в магазине: производитель объявил, что партия молока с такими-то номерами опасна. Магазин сверяет свой склад со списком. Библиотека это партия товара, а база CVE это список отзыва. Аналогия ломается тем, что «отзыв» касается не всех, кто купил партию: дыра опасна только там, где уязвимая функция реально вызывается.
- Зависимость (dependency): библиотека, которую использует твой проект. Прямая ты подключил сам (строка в
requirements.txt). Транзитивная пришла вместе с ней, потому что твоей библиотеке нужны свои библиотеки. requirements.txt: текстовый файл, где каждая строка это пакет Python и версия для установки черезpip(менеджер пакетов Python). Строкаrequests==2.19.0значит «ровно версия 2.19.0». Знаки==закрепляют точную версию, поэтому сканер точно знает, что у тебя стоит.- CVE (Common Vulnerabilities and Exposures): публичный номер конкретной уязвимости, вид
CVE-2018-18074(год открытия и порядковый номер). По номеру любой найдёт описание. - Серьёзность (severity): оценка по шкале CVSS от 0 до 10, которую в отчётах превращают в слова: LOW (низкая, до 4), MEDIUM (средняя, от 4 до 7), HIGH (высокая, от 7 до 9), CRITICAL (критичная, от 9).
- Trivy сравнивает версии из файлов проекта с локальной базой уязвимостей (vulnerability DB), которую сам скачивает и обновляет примерно раз в сутки. Режим
trivy fs(«file system») сканирует папку с проектом.
Из чего состоит отчёт: пакет, найденная версия (Installed), версия, в которой дыру исправили (Fixed Version), номер CVE, серьёзность, статус.
Мы добавили в requirements.txt строку requests==2.19.0 и запустили Trivy. Строка отчёта (полный вывод в задании 3):
│ requests │ CVE-2018-18074 │ HIGH │ fixed │ 2.19.0 │ 2.20.0 │ python-requests: Redirect from HTTPS to HTTP does not remove Authorization header │
Читаем слева направо. Библиотека requests. Уязвимость CVE-2018-18074. Серьёзность HIGH. Статус fixed: исправление существует. Установлена версия 2.19.0, исправлено в 2.20.0. Суть: если запрос перенаправили с защищённого адреса на незащищённый, библиотека не убирала заголовок Authorization (то есть отправляла пароль открытым текстом). Решение очевидно: поднять версию до 2.20.0 или выше.
Два параметра управляют шумом:
--severity HIGH,CRITICALоставляет в отчёте только серьёзные. В нашем опыте у той же версииrequestsнашлось ещё четыре уязвимости уровня MEDIUM; с этим порогом они не мешают.--ignore-unfixedпрячет уязвимости, для которых исправления ещё нет. Логика: если поднять версию нельзя, красный статус не даёт команде ничего сделать. Риск такой настройки: мы перестаём видеть дыру, которую ещё не закрыли, поэтому такие CVE нужно смотреть отдельно.- Код выхода. Trivy по умолчанию всегда завершается кодом 0, даже если нашёл дыры. Чтобы job в CI покраснел, нужно
--exit-code 1(найдено: код 1). Мы проверили: без этого флага команда с найденной HIGH-уязвимостью вернула 0.
Когда найдено то, что нельзя исправить сразу, есть файл .trivyignore: в нём строки с номерами CVE, которые сканер пропускает. Правило: рядом с каждой такой записью пишут причину и дату пересмотра, иначе «временное» исключение живёт вечно. Проверка целиком не отключается.
Прикинь сам: Trivy показал дыру
HIGH, но у неё нет исправленной версии. Падать ли проверке?
Обычно нет: флаг --ignore-unfixed пропускает дыры без исправления, иначе команда не сможет их устранить и перестанет смотреть на красное.
Осторожно: Два заблуждения. Первое: «зелёный trivy значит, что дыр нет». Он проверяет только то, что нашёл в файлах, и только по известным на сегодня CVE. Пустой requirements.txt даёт зелёный статус без единого сканирования (в опыте Target был -, «нечего сканировать»). Второе: «CVE в базе значит, что нас взломают». Уязвимость опасна, если уязвимый код выполняется в твоём сценарии и доступен атакующему. Поэтому отчёт читают с вопросом «достижимо ли это у нас», а не по принципу «красное значит пожар».
Главное: CVE это номер известной уязвимости, а Trivy сверяет зависимости с базой и ломает проверку только по тому, что можно исправить.
Проверь понимание: зачем нужна
--ignore-unfixedи какой риск у неё?
Ответ
Она скрывает уязвимости, для которых ещё нет исправленной версии: команда всё равно не может поднять версию, и красный статус только мешает. Риск: такая дыра остаётся открытой и невидимой в отчёте, поэтому нужно отдельно периодически смотреть полный список.
Дыры в чужом коде ясны. А что с правами у самого запуска? GITHUB_TOKEN.
Права GITHUB_TOKEN: принцип наименьших привилегий
Каждый запуск workflow получает токен, с которым можно действовать от имени репозитория: читать код, писать в него, создавать релизы. Если токен получил лишние права, а в workflow попал чужой код, вред будет ровно такой, какие права выданы.
Гостю в отеле выдают карту, которая открывает его номер и лифт, а не все двери. Мастер-ключ хранят у администратора. Принцип наименьших привилегий (least privilege): каждому выдают ровно то, что нужно для работы. Аналогия ломается тем, что карту гостя можно отобрать физически, а токен живёт до конца запуска и его нельзя «отозвать» посреди работы, поэтому права нужно ограничить заранее.
GitHub при каждом запуске создаёт временный токен GITHUB_TOKEN. Он действует только на время запуска. Чтобы код на runner мог к чему-то обращаться, токен передаётся в шаги автоматически. Какие права у него будут, решают два слоя:
- Настройка репозитория (Settings, Actions): по умолчанию токен может быть только читающим или читающим и пишущим, в зависимости от того, когда и как создан репозиторий или организация.
- Блок
permissions:в workflow. Он перекрывает настройку репозитория и позволяет задать права явно, по областям:contents(код),pull-requests,packages,id-token(запросить OIDC-токен) и другие. Значения:read,write,none. Если указать хоть одну область, все остальные становятсяnone.
Блок можно поставить на верхнем уровне (для всех jobs) и в отдельном job (для него одного).
permissions:
contents: read # для всех jobs по умолчанию: только читать код
jobs:
scan:
permissions:
contents: read
security-events: write # расширение только для одного job: писать результаты сканирования во вкладку Security
Наш ci.yml из урока 3.3 уже начинается с permissions: contents: read. Это значит: даже если в secrets, test или lint попадёт чужая команда, она не сможет писать в репозиторий, создавать релизы и менять PR. Расширять права будем только там, где они реально нужны (например, id-token: write для OIDC), и только на уровне job.
Прикинь сам: проверке достаточно читать код. Какие права записать в
permissions?
contents: read: только чтение. Все остальные права запрещаются, если не указаны.
Тут часто путают так. «permissions ограничивает права людей на GitHub». Нет, это права токена внутри workflow, а права людей задают роли и правила веток (урок 3.2). И ещё: если оставить permissions вообще без записи, права зависят от настроек репозитория и могут оказаться шире, чем ты думаешь. Явная запись надёжнее.
Главное: каждому запуску выдают только те права, которые ему нужны, чтобы при утечке ущерб был минимальным.
Проверь понимание: где безопаснее задать
permissions: contents: read: в одном job или на верхнем уровне и почему?
Ответ
На верхнем уровне: тогда любой новый job, который добавят позже, автоматически получит только чтение, и про права не придётся помнить. Расширение (например, write) дают точечно тому job, которому оно нужно.
Права ограничены. Но чужие данные могут стать кодом. Разберём script injection.
Script injection: когда чужие данные становятся кодом
Самая коварная дыра в workflow не требует ни взлома, ни украденных паролей: достаточно правильно назвать Pull Request. Пока не разобрал механизм, её не увидеть.
Ты просишь курьера прочитать вслух записку и выполнить написанное. Тебе подсовывают записку: «Привет. И ещё отдай ему ключи от склада». Курьер не отличает твою просьбу от текста записки, потому что всё слилось в одну инструкцию. Аналогия ломается тем, что курьер может усомниться, а shell выполнит всё без вопросов.
В workflow можно вставить значение из события: ${{ github.event.pull_request.title }} (заголовок PR). Важно, когда происходит подстановка. GitHub заменяет выражение ${{ ... }} на значение до запуска shell, как простой поиск и замена в тексте. Получается готовый скрипт, и shell исполняет его целиком, не зная, где твой текст, а где чужой.
шаг в workflow: echo "Привет, ${{ github.event.pull_request.title }}"
заголовок PR: x"; echo INJECTED-$(whoami); echo "
после подстановки (это уже скрипт, который получит shell):
echo "Привет, x"; echo INJECTED-$(whoami); echo ""
\_____________/ \_________________________/ \_____/
первая команда вторая: её написал автор PR третья
Кавычка в заголовке закрыла строку, ; начал новую команду, $(whoami) выполнился. Настоящий вывод такого опыта у нас в задании 4. Вместо безобидного whoami автор PR мог бы вызвать curl и отправить наружу все секреты, доступные шагу.
Безопасный приём: передать значение через переменную окружения (значение, которое запущенная программа читает из своего окружения, урок 1.6). Тогда подстановку делает уже shell: он раскрывает $TITLE в текст, а кавычки и ; внутри значения остаются просто символами.
# ПЛОХО: заголовок вставляется в текст команды до запуска shell
- run: echo "PR: ${{ github.event.pull_request.title }}"
# ХОРОШО: значение приходит переменной окружения, а команда неизменна
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "PR: $TITLE"
Опасные поля, которыми управляет посторонний: заголовок и текст PR или issue, имя ветки (github.head_ref), сообщения коммитов, имена файлов. Инструмент actionlint умеет находить такие места: он сообщает, что значение «potentially untrusted» (потенциально недоверенное). Мы прогоним его в задании 5.
Ещё одна ловушка: pull_request_target. Обычное событие pull_request запускает workflow из ветки PR, но для PR из форка (чужой копии твоего репозитория) не отдаёт секреты и даёт токену только чтение: чужому коду ничего ценного не достаётся. Событие pull_request_target работает наоборот: запускает workflow из основной ветки, с секретами и с правом записи, потому что оно нужно для безобидных задач вроде расстановки меток. Если в таком workflow сделать checkout кода из PR и запустить его, чужой код окажется в окружении с секретами. Правило: для проверки кода используй pull_request; pull_request_target не запускай на коде из PR.
Прикинь сам: заголовок PR подставлен в
run:через выражение. Автор PR назвал егоx"; echo INJECTED; echo ". Что выполнится?
Три команды вместо одной: подстановка идёт до запуска оболочки, и чужой текст становится кодом. Безопасно передавать значение через переменную окружения.
Главное: значения из события подставляются как текст в скрипт, поэтому чужие поля (заголовок, имя ветки) нельзя вставлять в
run:напрямую.
Проверь понимание: почему
echo "$TITLE"безопасно, аecho "${{ ... }}"нет?
Ответ
Выражение ${{ }} подставляется GitHub до запуска shell: значение целиком становится текстом скрипта, и кавычка с ; в нём превращается в конец команды и начало новой. Переменная окружения раскрывается уже внутри shell, как значение: символы в нём остаются символами, а команда неизменна.
Чужие данные укрощены. Что с чужими действиями? Supply chain.
Supply chain: сторонние actions и закрепление версий
В ci.yml ты пишешь uses: actions/checkout@v7.0.1. Это значит «скачай чужой код и выполни его на моём runner, с моими секретами». Кто гарантирует, что через месяц там будет тот же код?
Ты заказываешь на склад «поставку со стикером v7.0.1». Стикер (тег) можно переклеить на другую коробку: автор действия или тот, кто его взломал. Отпечаток пальца (хэш коммита, SHA) переклеить нельзя: другая коробка это другой отпечаток. Аналогия ломается тем, что отпечаток нечитаем для людей, поэтому к нему приписывают комментарий с версией.
- Тег (tag) в git это имя, которое указывает на коммит. Автор репозитория может передвинуть тег на другой коммит. Реальные атаки на популярные actions строились так: тег
v4переносили на вредоносный коммит, который печатал секреты в лог у всех, кто использует этот тег. - SHA коммита (40 шестнадцатеричных символов) однозначно определяет содержимое: изменить код, не изменив SHA, невозможно (урок 3.1). Поэтому закрепление (pinning) по SHA защищает от подмены.
- Минус SHA: его нечитаемо и неудобно обновлять руками. Решает это Dependabot: бот GitHub, который читает
.github/dependabot.ymlи сам открывает PR с новыми версиями actions и библиотек. Такой PR проходит твой CI, ты смотришь diff и вливаешь. Он также умеет обновлять SHA и комментарий с версией рядом с ним.
Вот как узнать SHA тега (настоящий вывод, задание 5):
$ git ls-remote --tags https://github.com/actions/checkout 'v7.0.1*'
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1
Первое поле это SHA коммита. Закреплённая запись в workflow:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Есть нюанс, на котором спотыкаются: у аннотированных тегов (с собственным описанием) команда выводит две строки, и вторая, с суффиксом ^{}, содержит SHA настоящего коммита. У aquasecurity/trivy-action v0.36.0 первая строка это a9c7b0f0... (объект тега), вторая ed142fd0... (коммит). Закреплять нужно коммит, то есть строку с ^{}.
В курсе для читаемости ставим точные теги проверенных версий (не плавающие @v7 и не @main), а для чувствительных workflow (доступ к облаку, публикация релизов) закрепляют по SHA.
Отдельно про версии, которые ты видишь в файле. Действие aquasecurity/trivy-action имеет свою нумерацию (сейчас v0.36.0), а сам Trivy, который оно запускает, свою (v0.74.0). Путать их нельзя: тега trivy-action@v0.74.0 не существует. Версию Trivy задают отдельным параметром version:.
Прикинь сам: автор действия переклеил тег
v7.0.1на другой коммит. Заметит ли это твой workflow при@v7.0.1?
Нет: тег сдвинется, и ты получишь другой код под тем же именем. Хеш коммита (SHA) подменить нельзя.
Тут часто путают так. «У action много звёзд, значит, безопасен». Звёзды не защищают от взлома аккаунта автора. И ещё: «Dependabot сам всё чинит». Он только предлагает обновление, а решаешь ты, после проверки CI и чтения diff.
Главное: тег можно передвинуть, хеш нельзя: для сторонних действий закрепляют хеш, а версию записывают в комментарий.
Проверь понимание: почему закрепление по SHA безопаснее тега, и какой у него минус?
Ответ
Тег можно передвинуть на другой код, а SHA однозначно определяет содержимое коммита, поэтому подмена невозможна. Минус: SHA нечитаем и обновлять его вручную неудобно, поэтому обновления поручают Dependabot и пишут рядом комментарий с версией.
Чужие действия закреплены. А как заходить в облако без вечного ключа? OIDC.
OIDC: вход в облако без хранимых ключей
Чтобы CI мог деплоить в облако (в теме 6), ему нужен доступ. Проще всего положить ключ облака в секреты репозитория. Но такой ключ живёт вечно, утёк один раз, и доступ открыт навсегда, пока кто-то не заметит. OIDC убирает саму необходимость хранить ключ.
Вместо копии ключа от офиса, лежащей в ящике, охрана на входе смотрит твой паспорт (подписан государством, срок действия, фото) и выдаёт временный пропуск на день. Украсть из ящика нечего. Аналогия ломается тем, что паспорт у тебя один на всю жизнь, а токен OIDC выпускают на каждый запуск заново.
OIDC (OpenID Connect) это способ доказать свою личность подписанным документом, который проверяется без общего пароля. Документ называется JWT (JSON Web Token, «вэб-токен в формате JSON»). Схема:
sequenceDiagram
participant R as runner (job)
participant G as GitHub
participant C as Облако (AWS, Yandex Cloud)
R->>G: 1. дай токен для этого запуска (id-token: write)
G-->>R: 2. JWT: кто я (repo, ветка), для кого (aud), срок (exp), подпись GitHub
R->>C: 3. вот JWT, дайте доступ
Note over C: 4. облако проверяет: подпись GitHub настоящая?<br>срок не вышел? sub подходит под правило доверия?
C-->>R: 5. временные ключи на минуты
Внутри JWT три части через точку: заголовок.содержимое.подпись.
- Заголовок (header): каким алгоритмом подписано (
alg), каким ключом (kid). - Содержимое (payload) с claims («утверждения»: поля с данными). Самые важные:
iss(issuer): кто выпустил токен (https://token.actions.githubusercontent.com);sub(subject): о ком речь. Для запуска на ветке видrepo:<владелец>/<репозиторий>:ref:refs/heads/<ветка>;aud(audience): для кого выпущен токен. Облако принимает токен, только еслиaudсовпало с ожидаемым;exp: время окончания (в секундах с 1 января 1970 года, как считает Linux);- плюс
repository,ref,workflow,actorи другие.
- Подпись (signature): по ней облако убеждается, что токен выпустил именно GitHub, а не подделка. Проверка идёт по открытым ключам GitHub (урок 2.6: принцип «подпись, проверяемая открытым ключом» тот же, что у сертификатов TLS).
Части закодированы base64url: это способ записать любые данные буквами и цифрами, чтобы их можно было передать в тексте. От обычного base64 он отличается заменой + и / на - и _ и тем, что в конце убирают знаки = (padding, «добивка» до длины, кратной 4). Кодировка не шифрует: любой прочитает содержимое, поэтому в payload секретов не бывает. Защищает от подделки подпись, а не тайна.
Возьмём средний кусок, декодируем (задание 1 делает это построчно) и получаем:
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:student/notes:ref:refs/heads/tmp/oidc-demo",
"aud": "sts.example.com",
"ref": "refs/heads/tmp/oidc-demo",
"repository": "ubuntu/notes",
"workflow": "oidc-demo",
"exp": 1790000900
}
Облако заранее настроено на правило вроде: «доверяю токенам от token.actions.githubusercontent.com, у которых aud равно sts.example.com и sub равно repo:student/notes:ref:refs/heads/main». Наш токен из ветки tmp/oidc-demo: sub не совпадает, роль не выдаётся. Именно условие на sub не даёт чужому репозиторию (у него свой sub) и чужой ветке получить твой доступ.
Что выигрываем: хранимого секрета нет, срок жизни короткий (минуты), права ограничены условием. Реальное подключение к облаку сделаем в уроке 6.3, здесь разбираем сам токен без облака.
Прикинь сам: JWT содержит
sub,audиexp. Какое из этих полей отвечает за срок жизни токена?
exp: время окончания в секундах с 1 января 1970 года. После него облако токен не примет.
Осторожно: «JWT зашифрован, поэтому безопасно печатать его в лог». Нет: содержимое читается всеми, а сам токен до конца срока жизни работает как пропуск, поэтому в лог его не печатают (печатать payload безопасно: там нет ничего секретного). И ещё: «условие на aud достаточно». Нет, главное условие это sub: без него подойдёт токен любого репозитория GitHub, если он запросит тот же aud.
Главное: OIDC даёт облаку подписанный документ вместо вечного ключа: облако проверяет подпись,
subи срок и выдаёт ключи на минуты.
Проверь понимание: что ограничивает условие на
subв доверии облака к GitHub?
Ответ
Какие именно запуски получат роль: конкретный репозиторий, ветку или окружение. Без условия на sub любой репозиторий на GitHub мог бы получить твои права в облаке.
Ключей в секретах больше нет. Как следить за устаревшими библиотеками? Dependabot.
Dependabot: бот, который следит за устаревшими версиями
Проверка Trivy ловит известные дыры в момент запуска. Но кто-то должен ещё и обновлять библиотеки, иначе красный статус появится, а исправлять его придётся всё в один день, срочно и сразу для десяти пакетов. Dependabot превращает эту работу в регулярные маленькие PR.
Это как помощник, который раз в неделю проходит по твоей кладовке, сверяет сроки годности и кладёт на стол записку: «молоко заканчивается, вот замена, купить?». Решение остаётся за тобой. Аналогия ломается тем, что помощник не пробует молоко: он не знает, подойдёт ли новая версия именно твоему проекту, это проверяет твой CI.
Dependabot встроен в GitHub. Работа идёт в четыре шага.
- Ты кладёшь в репозиторий файл
.github/dependabot.yml: какие «экосистемы» смотреть (pip,github-actionsи другие), где лежат файлы зависимостей и как часто проверять. - По расписанию Dependabot читает твои
requirements*.txtи строкиuses:в workflow и сравнивает версии с последними выпущенными. - Если есть новее, он создаёт ветку, меняет номер версии и открывает обычный Pull Request с описанием: что обновили, что изменилось в новой версии, есть ли в ней исправления безопасности.
- На этот PR запускается твой CI, точно как на человеческий. Зелёный: смотришь изменения и вливаешь. Красный: новая версия что-то сломала, и ты узнаёшь об этом до
main.
flowchart TD
N["новая версия библиотеки вышла"] --> D["Dependabot (раз в неделю)<br>замечает отличие от requirements.txt"]
D --> P["открывает PR<br>Bump requests from 2.19.0 to 2.20.0"]
P --> CI["запускается CI:<br>lint, test, secrets, trivy-fs"]
CI -->|зелёный| M["читаешь diff и вливаешь"]
CI -->|красный| X["новая версия что-то ломает:<br>не вливаешь, разбираешься"]
Есть два режима, и их путают. Version updates («обновления версий») идут по расписанию из dependabot.yml: бот предлагает подтянуть всё новое. Security updates («обновления безопасности») срабатывают сами, когда GitHub узнаёт об уязвимости в твоей зависимости: бот открывает PR именно с исправленной версией. Включаются они отдельно: Settings, Code security (там же Dependabot alerts, уведомления «в твоём проекте уязвима библиотека»). Аналогия: плановая проверка кладовой и внеплановый отзыв партии.
Ты закрепил в requirements.txt строку requests==2.19.0. Через неделю Dependabot открывает PR Bump requests from 2.19.0 to 2.20.0. Внутри правка одной строки: requests==2.20.0. Справа в PR статусы CI. Если их пять из пяти зелёные, вливаешь (squash, как в уроке 3.2). Обрати внимание: версию поднял бот, а ответственность за слияние твоя. Если же у библиотеки вышла версия 3.0, в которой убрали функцию, которую ты используешь, тесты упадут, и PR станет красным: именно для этого тесты и нужны. О том, как номер версии (major, minor, patch) говорит о риске обновления, будет в уроке 3.5.
Прикинь сам: Dependabot открыл PR с новой версией, CI зелёный. Можно ли вливать не читая?
Нет: зелёный CI говорит лишь, что тесты прошли. Diff и список изменений читает человек, решение остаётся за ним.
Тут часто путают так. «Dependabot и Trivy делают одно и то же». Нет: Trivy проверяет сейчас и красит PR, если в коде есть известная HIGH-дыра, а Dependabot готовит исправление заранее и без твоей просьбы. Вместе они замыкают круг: нашли, предложили замену, проверили тестами. Второе заблуждение: «бот сам всё вольёт». По умолчанию нет, сливает человек (автослияние можно включить, но для начала не стоит). Третье: если открытых PR от бота много, это не сбой: у него есть лимит открытых PR (по умолчанию пять на экосистему), остальные ждут.
Главное: Dependabot предлагает обновление PR, а CI проверяет, что оно ничего не сломало.
Проверь понимание: Dependabot открыл PR, и тесты в нём красные. Что это значит и стоит ли вливать?
Ответ
Новая версия библиотеки несовместима с твоим кодом или тестами. Вливать нельзя: смысл CI в том, чтобы поймать это до main. Открой лог упавших тестов, пойми, что изменилось, и либо поправь код, либо закрой PR и оставь старую версию, пока нет времени разбираться.
Проверок много, но нужны ли они, если их можно обойти? Нужны обязательные проверки.
Обязательные проверки: как CI реально защищает main
Красный статус в PR сам по себе ничего не блокирует: это просто значок. Если в репозитории не включена защита, любой с правом записи нажмёт «Merge» поверх красного, и все рубежи этого урока превратятся в рекомендации.
Турникет на входе в метро. Можно повесить табличку «проходите по билету», но пока нет турникета, пройдёт любой. Обязательные проверки (required status checks) это и есть турникет: кнопка слияния заблокирована, пока каждая отмеченная проверка не стала зелёной. Аналогия ломается тем, что турникет видит билет на входе, а GitHub ждёт именно проверок с конкретными именами.
В настройках репозитория (Settings, Rules или Branches) создаётся правило для ветки main. В нём включается пункт Require status checks to pass, и ты выбираешь имена проверок из списка. Что такое имя проверки? Это имя job, а для job с matrix к нему добавляется значение в скобках. Поэтому в нашем ci.yml получаются пять имён:
Job в ci.yml |
Имя проверки в PR |
|---|---|
lint |
lint |
test (matrix 3.13) |
test (3.13) |
test (matrix 3.14) |
test (3.14) |
secrets |
secrets |
trivy-fs |
trivy-fs |
Из этого следуют две вещи, которые ломают защиту незаметно.
- Переименовал job: правило ждёт старое имя. Если в
ci.ymlты переименуешьlintвlinter, то правило будет ждать проверкуlint, которой больше нет. PR повиснет в состоянииExpected: Waiting for status to be reported, а слить его будет нельзя. Поэтому в начале задания 5 мы сверяем, что имена job в файле те же, что в уроке 3.3. - Проверка, которая не запустилась. Если workflow не сработал вообще (например, из-за ошибки YAML), проверка не появляется, и PR снова «ждёт статус». Красным это не будет, будет именно ожидание: смотри на вкладку Actions.
Имена в выпадающем списке настроек появляются только для проверок, которые запускались в репозитории за последнюю неделю. Поэтому сначала открывают PR и ждут первого прогона, а потом включают правило.
PR с одним изменением: в app.py забыли закрыть скобку. lint краснеет, остальные четыре проверки зелёные. В блоке внизу PR кнопка «Merge pull request» серая, и подпись говорит: «Required statuses must pass before merging» (обязательные проверки должны пройти). Ты исправляешь строку, пушишь коммит, workflow запускается заново, все пять зелёные, кнопка активна. Без правила кнопка была бы зелёной даже при красном lint.
Прикинь сам: как выбрать проверку в правиле защиты
main: по имени job или по имени в PR?
По имени, как оно показано в PR: для matrix это test (3.13), а не test. При неверном имени PR будет ждать статус с таким именем, которого никто не шлёт, и не сольётся.
Осторожно: «Правило действует на всех». Владельцы репозитория и администраторы могут обойти его, если в настройках не включён запрет обхода. Для рабочего репозитория запрещают обход и себе: единичное «срочно, потом починю» как раз и приводит в продакшен красный код. Второе заблуждение: «достаточно требовать test». Проверка, которую не требуют, остаётся советом: secrets и trivy-fs не блокируют слияние, пока их нет в списке.
Главное: красный значок сам ничего не блокирует: турникетом служит правило с обязательными проверками.
Проверь понимание: ты добавил в
ci.ymlещё один jobtypecheck. Он красный, но PR сливается. Почему?
Ответ
Имя typecheck не добавлено в обязательные проверки main. Правило ждёт только перечисленные имена, новый job для него необязателен, и красный статус остаётся лишь значком. Нужно открыть правило и добавить typecheck.
Одной защиты мало. Как сочетаются все рубежи?
Защита в глубину: какой рубеж что ловит
После пяти инструментов легко решить, что «одного хватит». Но каждый рубеж ловит свой класс ошибок и слеп в других. Понимание, кто что видит, объясняет, почему в CI их пять, а не один, и куда смотреть, когда что-то просочилось.
Дом защищают замок на двери, сигнализация, камеры во дворе и соседи. Вор, обошедший замок, натыкается на сигнализацию. Каждый уровень не идеален, вместе они надёжны. Это называют защитой в глубину (defense in depth). Аналогия ломается тем, что в доме уровни независимы, а в CI один общий слабый элемент (например, права токена) обесценивает остальные: поэтому permissions стоит на верхнем уровне у всех.
Вот кто что ловит и чего не видит:
| Рубеж | Ловит | Не видит |
|---|---|---|
lint (ruff) |
ошибки и небрежности в твоём коде | секреты, чужие библиотеки |
test |
сломанное поведение | безопасность |
secrets (TruffleHog) |
ключи и токены в истории коммитов | уязвимости в коде и библиотеках |
trivy-fs |
известные CVE в зависимостях | секреты, свои ошибки логики |
permissions |
ограничивает ущерб, если что-то прошло | сам по себе ничего не находит |
| Dependabot | устаревшие версии, готовит обновление | уязвимость нулевого дня (о ней ещё никто не знает) |
Защита main |
не даёт слить красный PR | ничего, если проверки плохо настроены |
Возьмём один PR и посмотрим, кто его остановит. Автор добавил файл с токеном: остановит secrets (строгий режим). Автор поднял requests до версии с дырой: остановит trivy-fs. Автор поставил в workflow echo "${{ github.event.pull_request.title }}": остановит ревью глазами и actionlint, ни Trivy, ни TruffleHog этого не видят. Автор забыл пробел, и код не запускается: остановит lint. Каждый случай пойман разным рубежом.
Из таблицы видно и то, чего нет в нашем CI: проверки своего кода на опасные конструкции (SAST) и атак на запущенный сервис (DAST). Мы осознанно оставили их за рамками, но знаем, что они закрыли бы.
Прикинь сам: зависимость с известной дырой и токен в истории: какие рубежи сработают?
trivy-fs поймает дыру в библиотеке, secrets найдёт токен в истории. Линтер и тесты не видят ни того, ни другого.
Тут часто путают так. «Все пять зелёные, значит, безопасно». Зелёный статус значит только «известные нам классы проблем не найдены». Новую уязвимость, о которой ещё никто не написал, не найдёт никто. Поэтому остаются права по минимуму, ротация секретов и внимание человека на ревью.
Главное: каждый рубеж ловит свой класс ошибок и слеп в других, поэтому их несколько.
Проверь понимание: какой рубеж остановит PR, где автор дал
GITHUB_TOKENправаcontents: writeбез причины?
Ответ
Ни один из пяти автоматических: permissions сам по себе ничего не ищет. Это ловит человек на ревью, а actionlint подскажет только о синтаксических ошибках. Поэтому правки в .github/workflows/ просят проверять особенно внимательно (у GitHub для этого есть CODEOWNERS, обязательное ревью владельца файла).
Рубежей много, и они могут шуметь. Что делать с усталостью от красного?
Шум и усталость от красного: как не научить команду игнорировать сканеры
Опаснее всего не сканер, который молчит, а сканер, на красное от которого перестали смотреть. Если каждый PR краснеет из-за дыр, которые нельзя исправить, люди начинают нажимать «перезапустить» и сливать. Защита превращается в ритуал.
Автомобильная сигнализация, которая срабатывает от кошки. Через неделю соседи перестают выглядывать в окно, а через месяц настоящий вор ходит мимо под её вой. Аналогия ломается тем, что сигнализацию можно отрегулировать один раз, а список уязвимостей растёт каждый день.
Сообщество называет это alert fatigue (усталость от оповещений). Бороться с ней можно тремя приёмами, и все три уже есть в уроке.
- Выбор порога.
--severity HIGH,CRITICALоставляет серьёзные. Сотни LOW и MEDIUM не блокируют PR, но о них не забывают: их смотрят планово, не по тревоге. - Отсев того, что не исправить.
--ignore-unfixedпрячет дыры без готового исправления. Красный статус должен означать «есть действие, которое можно сделать сейчас». - Осознанные исключения.
.trivyignoreс причиной и датой пересмотра. Исключение без срока со временем превращается в дыру, о которой все забыли.
У TruffleHog то же самое: режим статусов (verified, unverified, unknown) выбирает баланс между «не пропустить» и «не шуметь».
У команды красный trivy-fs: три CVE. Смотрим отчёт. Первая: HIGH, fixed, версия исправлена в 2.20.0. Действие есть: поднять версию. Вторая: MEDIUM, отфильтрована порогом, сегодня не мешает. Третья: HIGH, статус affected без исправления, --ignore-unfixed её скрыл. В итоге красный остался из-за одной причины с понятным действием. Команда чинит, статус зелёный, а в календаре стоит пункт «в пятницу пересмотреть скрытые CVE».
Прикинь сам: каждый PR краснеет из-за дыры, которую нельзя исправить. Что произойдёт с командой?
Люди перестанут смотреть на красное и однажды пропустят настоящую проблему. Это alert fatigue.
Главное: проверка должна краснеть только по тому, что можно исправить сейчас, иначе сигнал теряет ценность.
Проверь понимание: почему
--ignore-unfixedне делает проект менее защищённым, хотя скрывает уязвимости, и в каком случае это всё же риск?
Ответ
Скрытая уязвимость не исправима прямо сейчас, красный статус не дал бы никакого действия, а только приучил бы сливать поверх красного. Риск в том, что дыра продолжает существовать: поэтому скрытые CVE раз в какое-то время просматривают отдельно и смотрят, не вышло ли исправление.
Остался навык: читать результат проверок в PR.
Как читать результат проверок в Pull Request
Пять проверок в PR дают пять значков. Если не знаешь, что значит каждый и куда нажать, красный значок превращается в «что-то сломалось, не знаю что».
Это табло у рейса в аэропорту: «вылетел», «задержан», «отменён», «идёт посадка». Табло не объясняет причину, но показывает, куда идти за подробностями (к стойке). Здесь «стойка» это лог запуска. Аналогия ломается тем, что табло врёт редко, а CI иногда краснеет по случайной причине (сеть, лимиты), и тогда помогает перезапуск.
Внизу каждого PR блок Checks (проверки). У каждой проверки значок и текст:
- зелёная галочка: все шаги job выполнились с кодом выхода 0;
- красный крестик: какой-то шаг завершился ненулевым кодом (напомню, урок 1.6: код 0 значит «успех», остальное «ошибка»);
- жёлтый кружок: job ещё идёт или ждёт свободной машины;
- серый значок: job пропущена (например, условием
if:) или отменена (новый коммит отменил устаревший запуск из-заconcurrency).
Чтобы понять причину, нажми Details рядом с красной проверкой. Откроется страница запуска: слева job, внутри них шаги, красный шаг раскрыт, под ним последние строки вывода. Причина почти всегда в нескольких последних строках перед первым красным шагом. Кнопка Re-run jobs в правом верхнем углу повторяет запуск: помогает при сбое сети или лимите, но не поможет, если код действительно плохой.
Checks
[x] lint красный -> Details -> шаг "Линтер" -> E501 line too long
[v] test (3.13) зелёный
[v] test (3.14) зелёный
[v] secrets зелёный
[ ] trivy-fs идёт -> подожди 1-2 минуты
У lint красный крестик. Открываем Details. Видим шаг Линтер (название мы дали сами в ci.yml) и в конце строки вида app.py:42:121: E501 Line too long (130 > 120). Читаем: файл app.py, строка 42, колонка 121, правило E501, в строке 130 символов при лимите 120 (из ruff.toml). Чиним строку, пушим, все значки пересчитываются. Тот же вывод ты видишь локально по make lint, поэтому практичнее всего перед push запускать make lint и make scan у себя.
Прикинь сам: в блоке Checks
lintкрасный,trivy-fsещё идёт. С чего начать?
С красного: открыть Details, найти шаг и первую ошибку. Идущую проверку не ждут, чтобы начать чинить.
Тут часто путают так. «Красный CI это вина системы». Чаще это честный отказ: не прошла проверка. Второе: «перезапущу, вдруг пройдёт». Если красный воспроизводится, перезапуск ничего не даст, а привычка тыкать Re-run прячет настоящие нестабильные тесты.
Главное: красный значок указывает, куда идти: Details, красный шаг, первая ошибка в логе.
Проверь понимание:
secretsкрасный, аlintиtestзелёные. Какую проверку и какой вывод смотреть первыми?
Ответ
Details у secrets, шаг TruffleHog. В его выводе будет найденный детектор, коммит и файл, где находится секрет. Начинать нужно с ротации найденного секрета, а затем с чистки истории и правки кода.
Теории хватит. Дальше практика: разбираем токен и запускаем сканеры.
Практика
Задания 1-4 выполняются у тебя в терминале и ничего не требуют от GitHub: сканеры ставятся как обычные программы. Задание 5 собирает всё в проект и отправляет на GitHub. Значения хэшей, токенов и времени у тебя будут другие: это нормально.
Задание 1. Читаем токен OIDC руками
Цель: разобрать JWT на части и увидеть его claims, то есть понять, что именно облако проверяет.
Предскажи: сколько частей у JWT, что окажется в каждой и сможешь ли ты прочитать содержимое без всяких ключей?
Ответ
Три части через точку: заголовок, содержимое и подпись. Заголовок и содержимое кодированы base64url, это не шифрование, поэтому читаются без ключа. Подпись без ключа GitHub проверить нельзя, но и читать её незачем.
Шаги:
- Нужен
jq: программа, которая печатает JSON красиво и вынимает из него поля. Поставь и проверь:
sudo apt-get install -y jq
jq --version
- Положи в переменную образец токена. Он собран по документации GitHub из выдуманных данных, реальным доступом не обладает.
JWT='...'присваивает строку переменной оболочки:
JWT='eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCIsICJraWQiOiAiZGVtby1rZXktMSJ9.eyJpc3MiOiJodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwic3ViIjoicmVwbzpzdHVkZW50L25vdGVzOnJlZjpyZWZzL2hlYWRzL3RtcC9vaWRjLWRlbW8iLCJhdWQiOiJzdHMuZXhhbXBsZS5jb20iLCJyZWYiOiJyZWZzL2hlYWRzL3RtcC9vaWRjLWRlbW8iLCJyZXBvc2l0b3J5Ijoic3R1ZGVudC9ub3RlcyIsInJlcG9zaXRvcnlfb3duZXIiOiJzdHVkZW50Iiwid29ya2Zsb3ciOiJvaWRjLWRlbW8iLCJhY3RvciI6InN0dWRlbnQiLCJldmVudF9uYW1lIjoicHVzaCIsInNoYSI6IjNkM2M0MmU1YWFjNWJhODA1ODI1ZGE3NjQxMGMxODEyNzNiYTkwYjEiLCJpYXQiOjE3OTAwMDAwMDAsIm5iZiI6MTc4OTk5OTcwMCwiZXhwIjoxNzkwMDAwOTAwfQ.Jdnw5ymJtaPVADFuMzPzvF4xvSoyDdaDhAV6M0omXCc'
- Раздели токен на части. Разбор:
echo "$JWT"печатает переменную,tr '.' '\n'заменяет каждую точку на перевод строки (так каждая часть окажется на своей строке),awk '{print NR": "length($0)" символов"}'для каждой строки печатает её номер (NR) и длину (length($0)).
echo "$JWT" | tr '.' '\n' | awk '{print NR": "length($0)" символов"}'
Что должно получиться:
1: 68 символов
2: 514 символов
3: 43 символов
Как читать вывод: три строки это три части: заголовок (68 символов), содержимое (514), подпись (43).
- Расшифруй заголовок и содержимое. Разбор по частям:
cut -d. -f2берёт вторую часть по разделителю.(вcutключ-dразделитель,-fномер поля, урок 1.2);tr '_-' '/+'возвращает символы обычного base64:_становится/, а-становится+(trзаменяет символы попарно по позициям);- цикл
whileдописывает знак=в конец, пока длина не разделится на 4 без остатка.${#PAYLOAD}это длина строки,$(( ... % 4 ))остаток от деления на 4; base64 -dдекодирует (-dот decode),jq '{...}'выбирает из JSON перечисленные поля.
echo "$JWT" | cut -d. -f1 | tr '_-' '/+' | { read -r h; while [ $(( ${#h} % 4 )) -ne 0 ]; do h="$h="; done; echo "$h" | base64 -d; echo; }
PAYLOAD=$(echo "$JWT" | cut -d. -f2 | tr '_-' '/+')
echo "до padding: ${#PAYLOAD} символов, остаток от деления на 4: $(( ${#PAYLOAD} % 4 ))"
while [ $(( ${#PAYLOAD} % 4 )) -ne 0 ]; do PAYLOAD="$PAYLOAD="; done
echo "после padding: ${#PAYLOAD} символов, хвост: ${PAYLOAD: -4}"
echo "$PAYLOAD" | base64 -d | jq '{iss, sub, aud, ref, repository, workflow, exp}'
Что должно получиться:
{"alg": "RS256", "typ": "JWT", "kid": "demo-key-1"}
до padding: 514 символов, остаток от деления на 4: 2
после padding: 516 символов, хвост: fQ==
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:student/notes:ref:refs/heads/tmp/oidc-demo",
"aud": "sts.example.com",
"ref": "refs/heads/tmp/oidc-demo",
"repository": "ubuntu/notes",
"workflow": "oidc-demo",
"exp": 1790000900
}
Как читать вывод: первая строка это заголовок: алгоритм подписи RS256 и имя ключа. 514 при делении на 4 даёт остаток 2, поэтому не хватает двух знаков =, и цикл дописал их: хвост fQ==. Дальше поля payload: кто выпустил (iss), о ком (sub: репозиторий и ветка), для кого (aud), время окончания (exp).
- Переведи
expв дату и посчитай срок жизни.date -u -d @Nпечатает дату для числа секунд с 1 января 1970 года (-uвремя UTC, то есть по Гринвичу). В образце есть ещё полеiat(время выпуска) со значением 1790000000, в вывод оно не попало:
date -u -d @1790000900 '+%F %T'
echo "срок жизни образца: $(( 1790000900 - 1790000000 )) секунд"
2026-09-21 14:28:20
срок жизни образца: 900 секунд
Образец я собрал сам, поэтому 900 секунд это наш выбор. Настоящий срок у токена GitHub посмотри в живом запуске: вычти iat из exp.
- Посмотри, что будет, если забыть padding:
echo "$JWT" | cut -d. -f2 | tr '_-' '/+' | base64 -d | tail -c 40
,"exp":1790000900}base64: invalid input
Содержимое напечаталось, но декодер пожаловался: не хватало =. Так выглядит частая ошибка при разборе JWT руками.
Настоящий токен на GitHub (не прогонялось). Для сравнения можно получить токен из живого запуска. Создай ветку и файл .github/workflows/oidc-demo.yml. Разбор: permissions: id-token: write разрешает запрашивать токен; переменные ACTIONS_ID_TOKEN_REQUEST_URL и ACTIONS_ID_TOKEN_REQUEST_TOKEN выдаёт сам runner при этом разрешении; curl -H "Authorization: bearer ..." просит токен, jq -r .value вынимает его из ответа; &audience=sts.example.com задаёт aud. Остальное ты уже разобрал выше.
cd ~/notes
git switch main && git pull
git switch -c tmp/oidc-demo
mkdir -p .github/workflows
cat > .github/workflows/oidc-demo.yml <<'EOT'
name: oidc-demo
on: push
permissions:
contents: read
id-token: write # разрешение запросить OIDC-токен
jobs:
show:
runs-on: ubuntu-24.04
steps:
- name: Показать claims токена
run: |
# запрашиваем JWT у GitHub, адрес и токен запроса выдаёт runner
JWT=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.example.com" | jq -r .value)
# JWT состоит из трёх частей через точку, нужна средняя (payload)
PAYLOAD=$(echo "$JWT" | cut -d. -f2 | tr '_-' '/+')
# добавляем padding base64, иначе декодер ругается
while [ $(( ${#PAYLOAD} % 4 )) -ne 0 ]; do PAYLOAD="$PAYLOAD="; done
echo "$PAYLOAD" | base64 -d | jq '{iss, sub, aud, ref, repository, workflow, exp}'
EOT
git add .github/workflows/oidc-demo.yml
git commit -m "tmp: показать claims OIDC"
git push -u origin tmp/oidc-demo
Во вкладке Actions открой запуск oidc-demo, шаг Показать claims токена. Там будет JSON с теми же полями, что у образца, но твои: repository твой, sub заканчивается на refs/heads/tmp/oidc-demo, exp свежий. Формат вывода я взял из документации GitHub, сам запуск не делал.
Объясни себе:
- Почему в логе безопасно печатать payload, но нельзя печатать сам JWT?
- Что облако должно проверить, кроме подписи?
- Что изменится в
sub, если запустить тот же workflow из другой ветки или другого репозитория?
Типичные ошибки:
base64: invalid input: не добавлен padding или не заменены_-. Повтори шаги 4-6.Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variableв живом запуске: у job нетid-token: writeвpermissions. Текст ошибки взят из документации GitHub, не воспроизводился.- Workflow не запустился: файл лежит не в
.github/workflows/или отступы в YAML сломаны (смотри вкладку Actions).
Убери за собой:
git switch main
git branch -D tmp/oidc-demo
git push origin --delete tmp/oidc-demo
Задание 2. Ловим секреты локально: TruffleHog
Цель: поставить TruffleHog, увидеть, что он находит и чего не находит, и почувствовать разницу режимов.
Предскажи: мы закоммитим ключ AWS из официальной документации (AKIAIOSFODNN7EXAMPLE) и выдуманный токен GitHub. Какой из них найдёт строгий режим verified,unverified,unknown?
Ответ
Найдётся только токен GitHub. Ключ AWS из документации сканер знает как пример и пропускает намеренно. Для ключа AWS в нашем опыте не нашлось ничего даже в строгом режиме.
Шаги:
- Поставь TruffleHog и Trivy (Trivy пригодится в задании 3). Скрипты установки лежат в репозиториях самих проектов. Разбор:
curl -sSfL URLскачивает скрипт (-fошибка при HTTP-ответе с ошибкой,-Lидти по перенаправлениям,-sSтихо, но показывать ошибки),| sh -s -- -b ~/.local/bin v3.97.9передаёт скрипт на выполнение оболочкеsh; всё после--это аргументы скрипта:-bпапка установки, последнее слово версия. Папка~/.local/binэто личная папка программ пользователя. Чтобы оболочка находила программы в ней, дописываем её вPATH(список папок, где оболочка ищет команды).
export PATH="$HOME/.local/bin:$PATH"
curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b ~/.local/bin v3.97.9
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b ~/.local/bin v0.74.0
trufflehog --version
trivy --version | head -1
Что должно получиться: установщики пишут по три строки. Архитектура в них зависит от твоего компьютера (у меня arm64, у тебя может быть amd64).
trufflesecurity/trufflehog info checking GitHub for tag 'v3.97.9'
trufflesecurity/trufflehog info found version: 3.97.9 for v3.97.9/linux/arm64
trufflesecurity/trufflehog info installed /home/ubuntu/.local/bin/trufflehog
aquasecurity/trivy info checking GitHub for tag 'v0.74.0'
aquasecurity/trivy info found version: 0.74.0 for v0.74.0/Linux/ARM64
aquasecurity/trivy info installed /home/ubuntu/.local/bin/trivy
trufflehog 3.97.9
Version: 0.74.0
Команда export PATH=... действует только в текущем терминале. Чтобы не повторять её после каждого входа, добавь ту же строку в ~/.bashrc.
- Проверь чистый репозиторий. Разбор команды:
trufflehog git file://.сканирует git-репозиторий в текущей папке (file://.это адрес репозитория на диске);--no-updateне проверять обновления самой программы;--results=...какие статусы показывать (таблица в теории);--failзавершиться с ненулевым кодом, если что-то найдено.$?это код выхода последней команды.
cd ~/notes
git switch main && git pull
trufflehog git file://. --no-update --results=verified,unverified,unknown --fail; echo "код выхода: $?"
Что должно получиться:
🐷🔑🐷 TruffleHog. Unearth your secrets. 🐷🔑🐷
2026-09-30T12:15:56Z info-0 trufflehog running source {"source_manager_worker_id": "6Cp7S", "with_units": true}
2026-09-30T12:15:56Z info-0 trufflehog scanning repo {"source_manager_worker_id": "6Cp7S", "unit_kind": "dir", "unit": "/tmp/trufflehog-1690-2598650768", "repo": "file:///home/ubuntu/notes"}
2026-09-30T12:15:56Z info-0 trufflehog finished scanning {"chunks": 8, "bytes": 15028, "verified_secrets": 0, "unverified_secrets": 0, "scan_duration": "13.409334ms", "trufflehog_version": "3.97.9", "verification_caching": {"Hits":0,"Misses":0,"HitsWasted":0,"AttemptsSaved":0,"VerificationTimeSpentMS":0}}
код выхода: 0
Как читать вывод: строки с info-0 это журнал работы, он идёт в поток ошибок (stderr, урок 1.2); найденные секреты выводятся отдельно, в обычный поток (stdout). Главное: verified_secrets: 0 и unverified_secrets: 0 (ничего не найдено) и код выхода 0. Числа chunks и bytes у тебя будут другие. Дальше журнал скроем добавкой 2>/dev/null, чтобы видеть только находки.
- Создай ветку и закоммить ключ AWS из документации:
git switch -c chore/ci-security
cat > config_local.py <<'PYEOF'
# временный тест: фейковый ключ AWS из документации, аккаунта не существует
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
PYEOF
git add config_local.py && git commit -qm "test: ключ AWS из документации"
trufflehog git file://. --no-update --results=verified,unverified,unknown --fail 2>/dev/null; echo "код выхода: $?"
код выхода: 0
Как читать вывод: ключ в файле, режим самый строгий, а сканер молчит и код 0: детектор AWS намеренно не считает находкой пример из документации. Урок: сканер не «ловит всё, что похоже», а работает по своим правилам, и проверять его нужно на настоящем формате.
- Убери этот коммит и сделай выдуманный токен GitHub. Разбор команды генерации:
head -c 200 /dev/urandomберёт 200 случайных байтов,tr -dc 'A-Za-z0-9'оставляет только буквы и цифры (-dудалить,-cвсё, кроме перечисленного),head -c 36берёт первые 36 символов.$(...)подставляет результат в переменную,ghp_в начале задаёт форму токена GitHub.printfпечатает строку по шаблону,%sместо для подстановки значения.
git reset -q --hard HEAD~1
TOK="ghp_$(head -c 200 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 36)"
echo "$TOK"; echo "длина: ${#TOK}"
printf '# временный тест: выдуманный токен GitHub\nGITHUB_TOKEN = "%s"\n' "$TOK" > config_local.py
git add config_local.py && git commit -qm "test: выдуманный токен"
ghp_fZgkwu0lhrHxTfK0hlawtWtHb3PTMylIKOHO
длина: 40
Токен у тебя будет другой. Длина 40: четыре символа ghp_ и 36 случайных.
- Прогони три режима (
for m in ...; do ...; doneповторяет команду для каждого значения из списка):
for m in verified verified,unknown verified,unverified,unknown; do
echo "=== $m"
trufflehog git file://. --no-update --results=$m --fail 2>/dev/null
echo "код выхода: $?"
done
Что должно получиться:
=== verified
код выхода: 0
=== verified,unknown
код выхода: 0
=== verified,unverified,unknown
Found unverified result 🐷🔑❓
Detector Type: Github
Decoder Type: PLAIN
Raw result: ghp_fZgkwu0lhrHxTfK0hlawtWtHb3PTMylIKOHO
Version: 2
Token_type: Personal Access Token (classic)
Rotation_guide: https://howtorotate.com/docs/tutorials/github/
Commit: 6ce43906d448e4512332a3b006ff154b793ffc64
Email: Student <ubuntu@example.com>
File: config_local.py
Line: 2
Repository: file:///home/ubuntu/notes
Repository_local_path: /tmp/trufflehog-1900-1259962135
Timestamp: 2026-09-30 12:15:58 +0000
код выхода: 183
Как читать вывод: два мягких режима промолчали и вернули 0, строгий нашёл токен. Found unverified result значит статус unverified: форма подошла, но GitHub такого токена не знает (он выдуманный). Detector Type: Github какой детектор сработал, Raw result найденная строка, Commit коммит, где она появилась, File и Line место в файле, Rotation_guide ссылка на инструкцию по ротации. Код 183 значит «найдено» (при флаге --fail).
- Проверь статус
unknown. ПеременнаяHTTPS_PROXYнаправляет запросы через несуществующий прокси-сервер: сканер не сможет дозвониться до GitHub и не сумеет проверить токен.
HTTPS_PROXY=http://127.0.0.1:9 trufflehog git file://. --no-update --results=verified,unknown --fail 2>/dev/null | head -3
Found unverified result 🐷🔑❓
Verification issue: connection refused
Detector Type: Github
Как читать вывод: режим «мягкий» (verified,unknown), но токен пойман. Строка Verification issue: connection refused объясняет почему: проверить не удалось, и такой случай считается unknown, а не «безопасно». Для CI это важно: сбой сети не превращается в зелёный статус.
- Удали токен следующим коммитом и посмотри, что осталось в истории. Команда
git log -p -S'ghp_' --oneline:-S'ghp_'показывает коммиты, где число вхождений строки изменилось,-pпечатает изменения,--onelineсокращает заголовок.git diff --stat main...HEADпоказывает итоговые отличия ветки отmain.
git rm -q config_local.py && git commit -qm "test: убрали токен"
git log --oneline
git diff --stat main...HEAD
trufflehog git file://. --no-update --results=verified,unverified,unknown --fail 2>/dev/null | head -3; echo "код выхода: ${PIPESTATUS[0]}"
3979070 test: убрали токен
6ce4390 test: выдуманный токен
e184111 Заметки после урока 3.3
Found unverified result 🐷🔑❓
Detector Type: Github
Decoder Type: PLAIN
код выхода: 183
Как читать вывод: git diff --stat не напечатал ничего: итоговых отличий от main нет, файла нет ни там, ни там, и ревьюер в PR увидит «пустое» изменение. А TruffleHog всё равно нашёл токен, потому что он читает каждый коммит. ${PIPESTATUS[0]} это код выхода первой команды в конвейере (иначе $? показал бы код head).
- Ограничь диапазон, как это делает CI для PR: только коммиты ветки, которых нет в
main.
trufflehog git file://. --since-commit main --branch HEAD --no-update --results=verified,unverified,unknown --fail 2>/dev/null | head -2; echo "код выхода: ${PIPESTATUS[0]}"
Found unverified result 🐷🔑❓
Detector Type: Github
код выхода: 183
- Проверь, зачем нужен
fetch-depth: 0. Сделаем «мелкий» клон на один коммит (--depth 1, для локальных репозиториев нужен адресfile://) и обычный, полный:
cd ~ && git clone -q --depth 1 file:///home/ubuntu/notes shallow && git clone -q file:///home/ubuntu/notes full
echo "shallow: коммитов $(git -C shallow rev-list --count HEAD)"
trufflehog git file://$HOME/shallow --no-update --results=verified,unverified,unknown --fail 2>/dev/null | head -3; echo "код выхода: ${PIPESTATUS[0]}"
echo "full: коммитов $(git -C full rev-list --count HEAD)"
trufflehog git file://$HOME/full --no-update --results=verified,unverified,unknown --fail 2>/dev/null | head -3; echo "код выхода: ${PIPESTATUS[0]}"
Путь /home/ubuntu/notes замени на свой (echo ~/notes). git -C папка команда выполняет команду в другой папке, rev-list --count HEAD считает коммиты в истории.
shallow: коммитов 1
код выхода: 0
full: коммитов 4
Found unverified result 🐷🔑❓
Detector Type: Github
Decoder Type: PLAIN
код выхода: 183
В этом опыте в истории было 4 коммита (в ветку добавлены и ещё лишние тренировочные). У тебя число будет своё. Вывод: в мелком клоне токена как будто нет, в полном он виден. Так и в CI: без fetch-depth: 0 сканер слеп.
Убери за собой:
rm -rf ~/shallow ~/full
cd ~/notes
git switch main
git branch -D chore/ci-security
Объясни себе:
- Почему в опыте 3 токен не нашёлся ни в одном мягком режиме, хотя он лежит в файле?
- Секрет убрали из последнего коммита. Найдёт ли его TruffleHog и почему?
- Какой режим ты выберешь для боевого репозитория и почему?
Типичные ошибки:
bash: trufflehog: command not found(код выхода 127):~/.local/binне вPATH. Выполниexport PATH="$HOME/.local/bin:$PATH".- В журнале
BASE and HEAD commits are the same. TruffleHog won't scan anything.: так пишет action в CI, когда начало и конец диапазона совпали (например, пустой push). Сделай новый коммит. - Скрипт установки упал на
curl: (22) The requested URL returned error: нет доступа к GitHub или опечатка в адресе. Проверьcurl -I https://github.com.
Если сканер нашёл токен, не вставляй его в запрос к нейросети: замени на
<токен>и сразу отзови настоящий, как описано в разделе про секреты.
Задание 3. Ловим уязвимую зависимость: Trivy
Цель: увидеть, как Trivy находит известную дыру в requirements.txt, прочитать отчёт, исправить и исключить осознанно.
Предскажи: мы добавим в requirements.txt строку requests==2.19.0. Что покажет Trivy и какой будет код выхода при --exit-code 1? А если этот флаг убрать?
Ответ
Таблицу: пакет, установленная версия, версия с исправлением (Fixed Version), номер CVE и серьёзность. Код выхода 1, если есть уязвимости выбранной серьёзности. Без --exit-code 1 Trivy всегда завершается с 0.
Шаги:
- Trivy уже установлен в задании 2. Разбор команды:
trivy fs .сканирует папку (fs это file system);--scanners vulnискать только уязвимости в зависимостях (по умолчаниюtrivy fsещё ищет секреты в файлах, но секреты у нас ищет TruffleHog);--severity HIGH,CRITICALпорог серьёзности;--ignore-unfixedпропускать дыры без исправления;--exit-code 1вернуть 1, если найдено. В первый запуск Trivy скачивает базу уязвимостей (в журнале строкиDownloading vulnerability DB, несколько десятков секунд).
cd ~/notes
git switch main && git pull
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .; echo "код выхода: $?"
Что должно получиться: проект пока без зависимостей.
2026-09-30T12:16:23Z INFO [vuln] Vulnerability scanning is enabled
2026-09-30T12:16:23Z INFO Number of language-specific files num=0
2026-09-30T12:16:23Z WARN [report] Supported files for scanner(s) not found. scanners=[vuln]
Report Summary
┌────────┬──────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├────────┼──────┼─────────────────┤
│ - │ - │ - │
└────────┴──────┴─────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)
код выхода: 0
Как читать вывод: num=0 и Supported files ... not found: Trivy не нашёл ни одного файла с зависимостями. Тире в таблице значит «не сканировалось». Это не «чисто», а «нечего проверять». Различай такие вещи.
- Добавь старую версию
requestsи повтори проверку:
git switch -c chore/ci-security
echo "requests==2.19.0" >> requirements.txt
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .; echo "код выхода: $?"
Что должно получиться (строки журнала INFO в начале опущены):
Report Summary
┌──────────────────┬──────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├──────────────────┼──────┼─────────────────┤
│ requirements.txt │ pip │ 1 │
└──────────────────┴──────┴─────────────────┘
requirements.txt (pip)
======================
Total: 1 (HIGH: 1, CRITICAL: 0)
┌──────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬──────────────────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├──────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤
│ requests │ CVE-2018-18074 │ HIGH │ fixed │ 2.19.0 │ 2.20.0 │ python-requests: Redirect from HTTPS to HTTP does not remove │
│ │ │ │ │ │ │ Authorization header │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2018-18074 │
└──────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┴──────────────────────────────────────────────────────────────┘
код выхода: 1
Как читать вывод: сверху сводка: файл requirements.txt типа pip, одна уязвимость. Ниже подробно: Total: 1 (HIGH: 1, CRITICAL: 0). В таблице читай четыре колонки: Installed Version (у тебя) и Fixed Version (куда обновляться) главные, Severity показывает срочность, Title суть. Ссылка внизу ведёт к описанию. Код выхода 1 сделал бы красным job в CI. Числа в отчёте зависят от базы уязвимостей на день запуска: у тебя они могут отличаться.
- Посмотри на все серьёзности сразу и на поведение без
--exit-code:
trivy fs --scanners vuln --ignore-unfixed --quiet . 2>&1 | grep -E "Total|CVE-"
trivy fs --scanners vuln --severity HIGH,CRITICAL --quiet . >/dev/null 2>&1; echo "код выхода без --exit-code: $?"
Ключ --quiet убирает журнал, grep -E "Total|CVE-" оставляет строки с итогом и номерами CVE.
Total: 5 (UNKNOWN: 0, LOW: 0, MEDIUM: 4, HIGH: 1, CRITICAL: 0)
│ requests │ CVE-2018-18074 │ HIGH │ fixed │ 2.19.0 │ 2.20.0 │ python-requests: Redirect from HTTPS to HTTP does not remove │
│ │ CVE-2023-32681 │ MEDIUM │ │ │ 2.31.0 │ python-requests: Unintended leak of Proxy-Authorization head │
│ │ CVE-2024-35195 │ │ │ │ 2.32.0 │ requests: subsequent requests to the same host ignore cert v │
│ │ CVE-2024-47081 │ │ │ │ 2.32.4 │ requests: Requests vulnerable to .netrc credentials leak via │
│ │ CVE-2026-25645 │ │ │ │ 2.33.0 │ requests: Requests: Security bypass due to predictable tempo │
код выхода без --exit-code: 0
Как читать вывод: без фильтра по серьёзности у этой версии пять уязвимостей: одна HIGH и четыре MEDIUM. Каждая исправлена в своей версии, самая новая только в 2.33.0. Последняя строка показывает главный урок про код выхода: Trivy нашёл HIGH и всё равно вернул 0, потому что не просили --exit-code 1. В CI без этого флага job был бы зелёным при красном отчёте.
- Исправь: подними версию (на день проверки последняя версия
requestsбыла 2.34.2) и повтори проверку:
sed -i 's/requests==2.19.0/requests==2.34.2/' requirements.txt
cat requirements.txt
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 --quiet .; echo "код выхода: $?"
Команда sed -i 's/старое/новое/' файл заменяет текст прямо в файле (урок 1.2).
requests==2.34.2
Report Summary
┌──────────────────┬──────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├──────────────────┼──────┼─────────────────┤
│ requirements.txt │ pip │ 0 │
└──────────────────┴──────┴─────────────────┘
...
код выхода: 0
- Теперь ситуация «исправления нет или нельзя обновиться». Верни старую версию и внеси CVE в исключения с причиной и датой пересмотра:
echo "requests==2.19.0" > requirements.txt
printf '# CVE-2018-18074: пересмотреть до 2026-12-31, сервис не ходит по редиректам\nCVE-2018-18074\n' > .trivyignore
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 --quiet .; echo "код выхода: $?"
Report Summary
┌──────────────────┬──────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├──────────────────┼──────┼─────────────────┤
│ requirements.txt │ pip │ 0 │
└──────────────────┴──────┴─────────────────┘
код выхода: 0
Строки с # в .trivyignore комментарии. Проверка не выключена: она пропускает ровно один номер. Выдуманное обоснование в комментарии здесь только для примера: в реальном проекте ты сначала проверяешь, достижима ли дыра.
Убери за собой:
rm .trivyignore
git checkout -- requirements.txt
git switch main
git branch -D chore/ci-security
Объясни себе:
- Зачем
--ignore-unfixedи какой риск у этой настройки? - Чем
trivy fsотличается отtrivy image(образы разберём в уроке 4.8)? - Что делать, если исправленной версии нет, а CVE HIGH?
Типичные ошибки:
- Пустая таблица
-при пустомrequirements.txt: нечего сканировать. Не путай с «чисто». failed to download vulnerability DBс ошибкой вродеTOOMANYREQUESTS: лимит скачивания базы у реестра. Повтори позже, в боевом CI кэшируй папку~/.cache/trivy. Текст этой ошибки я не воспроизводил.make: *** [Makefile:22: scan] Error 1: не ошибка настройки, это результат:make scanвернул код 1, потому что Trivy нашёл дыру (разберём в задании 5).
Задание 4. Script injection своими руками
Цель: увидеть, как значение из события становится командой, и как переменная окружения это исправляет. Всё локально, без GitHub.
Предскажи: мы сделаем шаблон echo "Привет, @@TITLE@@", подставим заголовок x"; echo INJECTED-$(whoami); echo " и запустим. Что напечатает shell?
Ответ
Две строки: Привет, x и INJECTED-<твой пользователь>. Заголовок закрыл кавычку, ; начал вторую команду, и она выполнилась. Пустая третья строка это echo "".
Шаги:
- Приготовь папку и шаблон шага.
@@TITLE@@заменяет выражение GitHub. В<<'EOT'кавычки вокругEOTне дают оболочке раскрывать$внутри, поэтому текст запишется как есть. Значение заголовка кладём в переменную в одинарных кавычках: так$(whoami)остаётся текстом.
mkdir -p ~/inject-lab && cd ~/inject-lab
cat > step-template.sh <<'EOT'
echo "Привет, @@TITLE@@"
EOT
TITLE='x"; echo INJECTED-$(whoami); echo "'
echo "заголовок PR: $TITLE"
- Подставь заголовок в шаблон «до запуска shell», как это делает GitHub. Python нужен здесь только как инструмент поиска и замены текста:
replaceзаменяет@@TITLE@@на значение из аргумента.
python3 - "$TITLE" <<'PY'
import sys
t = open("step-template.sh").read().replace("@@TITLE@@", sys.argv[1])
open("step-final.sh", "w").write(t)
PY
echo "--- какой скрипт получился:"; cat step-final.sh
echo "--- запуск:"; bash step-final.sh
Что должно получиться:
заголовок PR: x"; echo INJECTED-$(whoami); echo "
--- какой скрипт получился:
echo "Привет, x"; echo INJECTED-$(whoami); echo ""
--- запуск:
Привет, x
INJECTED-ubuntu
Как читать вывод: после подстановки в файле одна строка с тремя командами. В INJECTED-ubuntu слово ubuntu это имя пользователя, под которым запущено (у тебя будет твоё). Автор заголовка выполнил команду, которую ты не писал.
- Безопасный вариант: значение приходит переменной окружения, шаблон неизменен.
cat > step-safe.sh <<'EOT'
echo "Привет, $TITLE"
EOT
TITLE="$TITLE" bash step-safe.sh
Привет, x"; echo INJECTED-$(whoami); echo "
Как читать вывод: заголовок напечатался буквально, как текст: ни ;, ни $(whoami) не сработали. TITLE="$TITLE" bash step-safe.sh передаёт значение только этой одной команде (приём «переменная перед командой» из урока 1.6).
Убери за собой:
cd ~ && rm -rf ~/inject-lab
Объясни себе:
- Чем отличается момент подстановки
${{ ... }}от момента раскрытия$TITLE? - Почему кавычки вокруг выражения не спасают?
- Какие ещё поля события, кроме заголовка PR, придумал бы использовать атакующий?
Типичные ошибки:
- Вместо
INJECTED-...напечатался сам текст$(whoami): значение было в двойных кавычках при присваивании и оболочка раскрыла его слишком рано, либо ты запустил безопасный вариант. Присваивай в одинарных кавычках. python3: command not found: на голой системе Python может быть без нужной команды. Проверьpython3 --version(в стенде курса версия 3.12).
Задание 5. Шаг проекта: ci.yml, Makefile и Dependabot
Цель: привести ~/notes к состоянию конца урока: make lint и make scan работают локально, в ci.yml пять проверок, есть .github/dependabot.yml, main требует эти проверки.
Предскажи: сколько проверок будет у PR (учти matrix из урока 3.3)?
Ответ
Пять: lint, test (3.13), test (3.14), secrets, trivy-fs. Именно эти имена выбираются в required checks (обязательных проверках).
Шаги:
- Ветка и
Makefile. В уроке 1.6 цельlintбыла скелетом (py_compile), а ruff появился в уроке 3.3. Теперьmake lintвызывает и ruff, аmake scanзапускает оба сканера. ЗамениMakefileцеликом. Отступ перед командами это TAB, не пробелы: если копируешь отсюда, проверьcat -A Makefile | grep -c '^\^I'(у меня 8 строк с TAB).
cd ~/notes
git switch main && git pull
git switch -c chore/ci-security
source .venv/bin/activate
cat > Makefile <<'EOT'
# Цели проекта "Заметки". Запуск: make <цель>
PYTHON ?= python3
PORT ?= 8080
# файл данных для make run: в /tmp, чтобы не нужны были права на /var/lib/notes
NOTES_DATA ?= /tmp/notes-dev.txt
.PHONY: run test lint scan
run: ## запустить сервис на 127.0.0.1:$(PORT)
PORT=$(PORT) NOTES_DATA=$(NOTES_DATA) $(PYTHON) app.py
test: ## прогнать unit-тесты
$(PYTHON) -m unittest -v
lint: ## синтаксис и линтер ruff (ruff ставится в .venv, урок 3.3)
$(PYTHON) -m py_compile app.py test_app.py
$(PYTHON) -m ruff check .
@echo "lint: ок"
scan: ## секреты в истории и CVE в зависимостях (как в CI, урок 3.4)
trufflehog git file://. --no-update --results=verified,unverified,unknown --fail
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .
EOT
Разбор изменений: в .PHONY добавлена цель scan (имя действия, не файла). В lint после проверки синтаксиса вызывается ruff check .; если ruff найдёт замечание, вернёт ненулевой код, make остановится и покажет красное (в 1.6 объяснено, что make прерывается при первой неудачной команде). @echo с @ печатает сообщение без показа самой команды. Цель scan запускает две команды из заданий 2 и 3: те же флаги, что будут в CI.
- Запусти обе цели:
make lint
make scan 2>&1 | grep -v "info-0"
Что должно получиться:
python3 -m py_compile app.py test_app.py
python3 -m ruff check .
All checks passed!
lint: ок
trufflehog git file://. --no-update --results=verified,unverified,unknown --fail
🐷🔑🐷 TruffleHog. Unearth your secrets. 🐷🔑🐷
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .
2026-09-30T12:16:23Z INFO [vuln] Vulnerability scanning is enabled
2026-09-30T12:16:23Z INFO Number of language-specific files num=0
2026-09-30T12:16:23Z WARN [report] Supported files for scanner(s) not found. scanners=[vuln]
Report Summary
┌────────┬──────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├────────┼──────┼─────────────────┤
│ - │ - │ - │
└────────┴──────┴─────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)
Как читать вывод: make печатает каждую команду перед выполнением, поэтому видно, что именно запущено. All checks passed! это ruff. У scan обе команды прошли без остановки; Trivy сообщает, что сканировать нечего (файла с зависимостями нет).
- Положи
.github/workflows/ci.yml: возьми файл из урока 3.3 и допиши в конец два job. Остальное (lint,test,permissions,concurrency) не трогай: имена job и шагов (lint,test,Установить инструменты,Линтер,Тесты) те же, что в 3.3, иначе названия проверок вmainразойдутся. Если твой файл из 3.3 отличается, сверь его с текстом ниже. Полный файл после правки:
name: CI
# Когда запускать: на каждый PR и на push в main (после слияния)
on:
pull_request:
push:
branches: [main]
# Токену достаточно читать код
permissions:
contents: read
# Новый коммит в ту же ветку отменяет устаревший запуск
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-24.04
# Зависшая задача не должна съедать часы: через 10 минут её остановят
timeout-minutes: 10
steps:
# Скачать код репозитория на раннер
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.0.0
with:
python-version: "3.13"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Установить инструменты
run: pip install -r requirements-dev.txt
# Та же цель Makefile, что ты запускаешь у себя: py_compile и ruff
- name: Линтер
run: make lint
test:
runs-on: ubuntu-24.04
timeout-minutes: 10
strategy:
# Пусть упавшая версия не скрывает результат другой
fail-fast: false
matrix:
python-version: ["3.13", "3.14"]
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.0.0
with:
python-version: ${{ matrix.python-version }}
# Внешних зависимостей у сервиса нет, ставить нечего
- name: Тесты
run: make test
# Добавлено в уроке 3.4: поиск секретов во всей истории PR
secrets:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0 # нужна история, а не один последний коммит
- name: TruffleHog
uses: trufflesecurity/trufflehog@v3.97.9
with:
# найденный секрет любого статуса блокирует PR (почему так, в теории)
extra_args: --results=verified,unverified,unknown
# Добавлено в уроке 3.4: известные уязвимости в зависимостях
trivy-fs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v7.0.1
- name: Trivy fs
uses: aquasecurity/trivy-action@v0.36.0
with:
version: v0.74.0 # версия самого Trivy, отдельно от версии action
scan-type: fs
scan-ref: .
scanners: vuln # секреты уже ищет TruffleHog, тут только CVE
severity: HIGH,CRITICAL
ignore-unfixed: true # не шуметь про уязвимости без исправления
exit-code: "1" # найдено: job красный
Разбор нового: secrets использует fetch-depth: 0 (зачем, ты проверил в задании 2), а extra_args передаёт сканеру тот же --results, что и make scan. У trivy-fs сначала uses с версией действия (v0.36.0), затем в with параметры: version версия самого Trivy, scan-type: fs и scan-ref: . значат «сканировать папку проекта», остальное те же флаги, что в make scan, только в виде ключей YAML (ignore-unfixed: true, exit-code: "1" в кавычках, потому что в with значения это строки).
Права: permissions: contents: read на верхнем уровне действует и на новые jobs, отдельные расширения им не нужны. Версии v3.97.9 и v0.36.0 взяты с страниц релизов на день проверки; перед боевым использованием проверь актуальные.
- Проверь файл сначала настоящим анализатором.
actionlintразбирает workflow и находит опечатки в ключах, неверные выражения и небезопасные подстановки. Скрипт загрузки кладёт программу в текущую папку, поэтому запускаем его из~/.local/bin:
curl -sSfL -o /tmp/download-actionlint.bash https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash
(cd ~/.local/bin && bash /tmp/download-actionlint.bash 1.7.12)
actionlint -version | head -1
actionlint; echo "код выхода: $?"
Скобки ( ... ) запускают команду в подоболочке: cd внутри них не меняет твою текущую папку. actionlint без аргументов проверяет все файлы в .github/workflows/.
1.7.12
код выхода: 0
Как читать вывод: пустой вывод и код 0 значат «замечаний нет». Чтобы увидеть, как выглядит ошибка, сломай ключ и запусти ещё раз:
sed -i 's/^concurrency:/concurency:/' .github/workflows/ci.yml
actionlint; echo "код выхода: $?"
sed -i 's/^concurency:/concurrency:/' .github/workflows/ci.yml
.github/workflows/ci.yml:14:1: unexpected key "concurency" for "workflow" section. expected one of "concurrency", "defaults", "env", "jobs", "name", "on", "permissions", "run-name" [syntax-check]
|
14 | concurency:
| ^~~~~~~~~~~
код выхода: 1
Ошибка показывает файл, строку и колонку, а в квадратных скобках вид проверки. Вторая команда sed возвращает опечатку обратно. Ту же проверку в CI даёт GitHub, но на своей стороне и позже, поэтому локальный запуск экономит круг PR.
- Создай
.github/dependabot.yml. Разбор ключей:version: 2версия формата файла; вupdatesперечислены «экосистемы»:github-actions(следить заuses:в workflow) иpip(следить заrequirements*.txt);directory: "/"где искать файлы (в корне репозитория);schedule: interval: "weekly"как часто проверять.
cat > .github/dependabot.yml <<'EOT'
version: 2
updates:
# обновления версий actions из workflow
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# обновления pip-зависимостей
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
EOT
pip install -q check-jsonschema
check-jsonschema --builtin-schema vendor.dependabot .github/dependabot.yml
check-jsonschema --builtin-schema vendor.github-workflows .github/workflows/ci.yml
check-jsonschema проверяет файл по официальной схеме: vendor.dependabot схема для dependabot.yml, vendor.github-workflows для workflow.
ok -- validation done
ok -- validation done
- Проверь закрепление по SHA. Узнай SHA для
actions/checkoutи сравни с числом из теории:
git ls-remote --tags https://github.com/actions/checkout 'v7.0.1*'
git ls-remote --tags https://github.com/aquasecurity/trivy-action 'v0.36.0*'
git ls-remote --tags https://github.com/aquasecurity/trivy-action v0.74.0
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1
a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 refs/tags/v0.36.0
ed142fd0673e97e23eac54620cfb913e5ce36c25 refs/tags/v0.36.0^{}
Как читать вывод: первая команда дала SHA checkout (по нему можно закрепить: actions/checkout@3d3c42e5... # v7.0.1). Вторая показала аннотированный тег: две строки, а настоящий коммит в строке с ^{}. Третья вернула пустой результат: тега v0.74.0 у action нет, это версия самого Trivy.
- Закоммить и открой Pull Request. Все пять проверок должны стать зелёными (результат GitHub я не проверял: запуск Actions недоступен вне GitHub, файлы проверены
actionlintиcheck-jsonschema).
git add Makefile .github/workflows/ci.yml .github/dependabot.yml
git commit -m "ci: secrets, trivy fs, dependabot; make lint и make scan"
git push -u origin chore/ci-security
gh pr create --fill
- Слей PR (squash, как в уроке 3.2). Потом в GitHub: Settings, Branches (или Rules), правило для
main: включи Require status checks to pass и добавьlint,test (3.13),test (3.14),secrets,trivy-fs. Проверь, что Dependabot включён: Settings, Code security.
Что должно получиться в PR:
lint pass
test (3.13) pass
test (3.14) pass
secrets pass
trivy-fs pass
Проверка из терминала (нужен gh):
gh pr checks
gh api repos/:owner/:repo/branches/main/protection/required_status_checks --jq '.contexts'
Объясни себе:
- Почему
permissions: contents: readстоит на верхнем уровне, а не только в одном job? - Зачем
concurrencyсcancel-in-progressи чем он опасен для деплоя? - Что произойдёт с PR от Dependabot, если он сломает тесты?
Типичные ошибки:
make: ruff: No such file or directory(Error 127): не активирован.venv. Выполниsource .venv/bin/activate. Так жеmake: trufflehog: No such file or directory, если~/.local/binне вPATH.- Required check висит как
Expected - Waiting for status to be reported: имя в правиле не совпадает с именем job (например,testвместоtest (3.13)). Выбирай из списка после хотя бы одного запуска. Resource not accessible by integration: job пытается сделать то, на что нет прав. Добавь право этому job, а не всему workflow. Текст ошибки взят из документации GitHub, не воспроизводился.Unable to resolve action aquasecurity/trivy-action@v0.74.0, unable to find version v0.74.0: перепутана версия action и версия Trivy. Тег у action такого нет (проверьgit ls-remoteиз шага 6). Текст ошибки GitHub, не воспроизводился, отсутствие тега проверено.
Если нейросеть предложила правки в
ci.ymlилиdependabot.yml, сверь версии действий и список прав с разделами урока и запустиactionlint.
Сломай и почини
Три сценария, поломки делаются скриптом на отдельной ветке. Сам скрипт не читай: цель в том, чтобы найти причину по симптомам, как на работе.
cd ~/notes
git switch main && git pull && git switch -c ci/break-drill
curl -fsSL -o /tmp/break-3.4.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/3.4/break.sh
bash /tmp/break-3.4.sh 1 # сценарии 1, 2, 3, а вернуть ветку в исходное состояние: fix
git push -u origin ci/break-drill
gh pr create --fill
Скрипт работает только на ветке ci/break-drill в ~/notes, без sudo, и сам делает нужные коммиты. Повторный запуск того же сценария ничего не дублирует. Сценарии можно накладывать по очереди на одну ветку. После разбора закрой PR без слияния, выполни bash /tmp/break-3.4.sh fix (вернёт ветку к состоянию до поломок) и удали ветку на GitHub.
Симптом
- Красный
secretsна PR, в диффе которого нет ничего подозрительного. - Workflow выполняет чужую команду: скрипт создаёт
inject-demo.ymlи печатает заголовок, с которым нужно открыть PR. В логе шага видна строка, которую ты не писал. - Красный
trivy-fsна PR, который вроде бы «мелко правит окружение», а зависимости не трогал.
Гипотезы
- Сценарий 1: ложное срабатывание? секрет удалён в последнем коммите, но остался в истории? ключ лежит в тестовом файле?
- Сценарий 2: значение из события вставлено прямо в
run? использованpull_request_target? - Сценарий 3: пакет прямой или транзитивный? есть ли версия с исправлением? нужна ли эта CVE твоему коду?
Проверки
# что нашёл сканер по коммитам ветки, локально
trufflehog git file://. --since-commit main --branch HEAD --no-update --results=verified,unverified,unknown --fail 2>/dev/null | head -12
# в каком коммите появилась строка
git log -p -S'ghp_' --oneline
# все опасные подстановки в workflow-файлах
grep -rn 'github.event' .github/workflows/
actionlint
# какие версии зависимостей закреплены и что о них думает Trivy
cat requirements.txt
trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 --quiet .
Исправление
Разбор всех сценариев
Сценарий 1. Порядок: (1) отозвать токен у провайдера, выпустить новый, положить в секреты CI; (2) проверить, не использовали ли старый (журнал провайдера); (3) убрать секрет из кода, читать из окружения; (4) при необходимости почистить историю (git filter-repo --replace-text), сделать force-push и попросить команду переклонировать репозиторий; (5) настоящее ложное срабатывание внести в исключения с комментарием, почему. Чистка истории без шага 1 бесполезна. В нашем учебном случае токен выдуманный, поэтому достаточно bash /tmp/break-3.4.sh fix. Симптом «дифф чистый, а secrets красный» получается потому, что PR сравнивает начало и конец ветки, а TruffleHog читает каждый коммит между ними.
Сценарий 2. Замени подстановку на переменную окружения:
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Привет, $TITLE"
Перезапусти проверку: в логе будет весь заголовок как текст, без выполненной команды. actionlint показывает эту проблему заранее: "github.event.pull_request.title" is potentially untrusted (проверено в стенде). Дополнительно: permissions минимальные, pull_request_target не использовать без острой необходимости, сторонние actions закреплять.
Сценарий 3. Обнови пакет до исправленной версии (requests==2.34.2). Если исправления нет, оцени, вызывается ли уязвимый код; если нет, добавь CVE в .trivyignore с комментарием и сроком пересмотра. Job целиком не выключай.
После разбора: bash /tmp/break-3.4.sh fix, закрыть PR без слияния, удалить ветку. Файл inject-demo.yml в main не мержи.
ИИ в помощь
Нейросеть быстро объяснит отчёт сканера и пройдётся по workflow в поисках дыр, но твоего репозитория не видит, а секреты ей отдавать нельзя: перед вставкой замени токены и ключи на <токен>. Общие правила: ИИ-помощник.
Задача: разобрать отчёт Trivy и решить, что чинить первым.
Вот отчёт Trivy по моему requirements.txt:
<вставь таблицу уязвимостей>
Объясни для каждой строки, что значит серьёзность и статус fixed, какие дыры можно закрыть обновлением, а какие нет.
Предложи порядок исправления и номер версии, до которой обновлять.
Проверь ответ: проверь номер версии по колонке Fixed Version отчёта и запусти trivy fs ещё раз после обновления. Типичная ошибка нейросетей: выдумать версию, которой нет, или посоветовать отключить проверку вместо обновления.
Задача: найти script injection в workflow.
Вот мой workflow:
<вставь ci.yml>
Найди шаги, где значения из github.event (заголовок PR, имя ветки, текст комментария) подставляются в run напрямую.
Для каждого покажи безопасный вариант через переменную окружения и объясни, почему он безопасен.
Проверь ответ: проверь каждую найденную строку по разделу про script injection и прогони файл через actionlint. Типичная ошибка: нейросеть пропускает подстановку в with: или не замечает, что опасно не только поле заголовка, но и имя ветки.
Задача: составить правило доверия для OIDC и проверить его.
Я хочу, чтобы мой workflow получал временный доступ в облако через OIDC, без хранимых ключей.
Репозиторий student/notes, деплой только из ветки main.
Опиши, какие поля JWT (sub, aud) нужно указать в правиле доверия, чтобы доступ был только у main этого репозитория, и что будет, если оставить правило слишком широким.
Проверь ответ: сверь поля с реальным JWT из задания 1 и проверь, что правило не пускает запуск из другой ветки. Типичная ошибка: нейросеть предлагает sub с подстановочным знаком для всего репозитория, и доступ получает любая ветка, включая чужие PR.
Словарик урока
| Термин | Простыми словами |
|---|---|
| Runner | Машина, на которой GitHub выполняет твой workflow. Каждый раз чистая |
| Секрет (secret) | Токен, пароль или ключ, дающий доступ к чему-то. Утёкший секрет считается скомпрометированным |
| Ротация (rotation) | Отозвать старый секрет и выпустить новый. Единственное, что закрывает утечку |
| Shift-left | Переносить проверки как можно раньше, на PR, пока ошибка дёшева |
| Secret scanning | Поиск ключей и токенов в коде и истории (TruffleHog, gitleaks) |
| SCA | Проверка чужих библиотек на известные уязвимости (Trivy, Dependabot) |
| SAST / DAST | Анализ своего кода без запуска / атака на запущенный сервис |
| Детектор (detector) | Правило TruffleHog: как выглядит ключ конкретного провайдера |
| Верификация (verification) | Проверка найденного секрета запросом к провайдеру: живой ли |
| verified / unverified / unknown | Статусы: подтверждён живым / форма подошла, провайдер не знает / проверить не удалось |
| Ложное срабатывание (false positive) | Сканер нашёл то, что на самом деле безопасно |
| fetch-depth: 0 | Просит checkout скачать всю историю, а не один коммит |
| Зависимость (dependency) | Чужая библиотека, которую использует проект. Прямая или транзитивная |
| CVE | Публичный номер известной уязвимости, вид CVE-2018-18074 |
| Severity / CVSS | Серьёзность уязвимости: оценка от 0 до 10, слова LOW, MEDIUM, HIGH, CRITICAL |
| Fixed Version | Версия библиотеки, в которой дыру исправили |
| .trivyignore | Файл со списком CVE, которые Trivy пропускает. К каждой записи нужны причина и срок |
| GITHUB_TOKEN | Временный токен запуска. Действует только на время workflow |
| permissions | Блок в workflow: какие права у GITHUB_TOKEN. Принцип наименьших привилегий |
| Least privilege | Каждому выдают ровно те права, что нужны для работы |
| Токен (token) | Длинная случайная строка-пропуск, которую сервис выдаёт программе вместо пароля |
| Ключ доступа (access key) | Пара «идентификатор + секретная часть» для облака |
| Ротация секрета | Отозвать утёкший секрет у выдавшего сервиса и выпустить новый |
| Продакшен (production) | Боевой сервер, на котором приложение работает для настоящих пользователей |
| Job | Независимая задача внутри workflow, выполняется на своей чистой машине |
| Защита в глубину (defense in depth) | Несколько разных рубежей вместо одного: что пропустил один, ловит другой |
| Alert fatigue | Усталость от оповещений: слишком много красного, и на него перестают смотреть |
| Required status checks | Обязательные проверки: пока они не зелёные, слияние в main заблокировано |
| OIDC / JWT | Вход по подписанному одноразовому документу (токену), без хранимого ключа |
| Script injection | Данные извне (заголовок PR) подставляются в команду и становятся кодом |
| pull_request_target | Событие, запускающее workflow из основной ветки с секретами. Не запускать на коде из PR |
| Supply chain | Цепочка, которой чужой код (библиотеки, actions) доходит до тебя |
| Закрепление по SHA (pinning) | Указывать в uses: хэш коммита, а не тег. Тег можно передвинуть |
| Dependabot | Бот GitHub, который открывает PR с обновлениями actions и зависимостей |
| Required check | Проверка, без зелёного статуса которой main не принимает слияние |
| OIDC | Способ доказать личность подписанным токеном без хранимого общего пароля |
| JWT | Токен формата заголовок.содержимое.подпись. Содержимое читается всеми, подделку не даёт подпись |
| Claims | Поля внутри токена: кто (sub), для кого (aud), кем выпущен (iss), срок (exp) |
| base64url | Способ записать данные буквами и цифрами. Не шифрование. В конце может не быть = |
| actionlint | Программа, проверяющая workflow: опечатки, выражения, небезопасные подстановки |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.
1. [junior] [часто] Чем отличаются SAST, SCA и сканирование секретов? Что такое DAST?
Ответ
SAST читает исходный код и ищет опасные конструкции, например CodeQL или Semgrep. SCA проверяет зависимости на известные CVE: Dependabot, trivy fs. Сканирование секретов ищет пароли и токены в коде и истории git: gitleaks, TruffleHog. DAST атакует уже запущенное приложение снаружи, например OWASP ZAP. Первые три ставлю на каждый Pull Request, DAST гоняю на тестовом стенде, потому что ему нужно работающее приложение.
Что хотят услышать: SAST смотрит код, SCA смотрит зависимости, секреты отдельно, DAST работает по запущенному приложению, где что запускается в конвейере.
Красный флаг: «Это всё антивирус» или путает SAST и SCA; считает, что один сканер закрывает всё.
2. [junior] [часто] В репозитории нашли закоммиченный пароль от базы. Что делаешь?
Ответ
Первым делом меняю пароль в самой базе и там, где приложение его читает: это единственное, что закрывает дыру. Потом смотрю по журналу базы, не заходил ли кто-то посторонний. Затем убираю секрет из кода (читаю его из окружения или хранилища секретов) и, если нужно, чищу историю. Добавляю сканер секретов в CI, чтобы не повторилось.
Что хотят услышать: ротация раньше чистки истории, «считаем скомпрометированным», проверка использования, профилактика (сканер, .gitignore).
Красный флаг: «удалю файл и сделаю новый коммит» или «force-push решит проблему».
3. [middle] [часто] Нужно деплоить из GitHub Actions в облако. Где хранить ключ доступа?
Ответ
Нигде: настраиваю OIDC. Облако доверяет токену GitHub, роль выдаётся только репозиторию notes и ветке main (условие на sub), ключи временные. Job получает id-token: write и обменивает JWT на короткоживущие ключи. Статический ключ в секретах остаётся запасным вариантом.
Что хотят услышать: JWT, claims, условие на sub и aud, срок жизни минуты, нет секрета для кражи.
Красный флаг: «положу ключ администратора в Secrets, он же зашифрован».
4. [junior] [на скорость] Что делает permissions: contents: read в workflow и зачем оно?
Ответ
Ограничивает права временного токена GITHUB_TOKEN только чтением кода. Если в workflow найдут уязвимость или подменят action, злоумышленник не сможет писать в репозиторий, создавать релизы и менять PR. Расширяю права только тем jobs, которым нужно.
Что хотят услышать: least privilege (наименьшие привилегии), права на уровне workflow и job, примеры расширений (packages: write, id-token: write).
Красный флаг: «не знаю, копирую из примера» или permissions: write-all.
5. [junior] В CI упал trivy fs с HIGH CVE в зависимости. Твои действия?
Ответ
Смотрю в таблице пакет, версию и Fixed Version (версию с исправлением). Если исправление есть, поднимаю версию в requirements.txt, гоняю тесты и вливаю. Если нет, оцениваю, достижим ли уязвимый код, и при необходимости заношу CVE в .trivyignore с комментарием и датой пересмотра.
Что хотят услышать: чтение отчёта, прямые и транзитивные зависимости, осознанное исключение, а не отключение проверки.
Красный флаг: «отключу job, чтобы не мешал».
6. [junior] [на скорость] Зачем Dependabot, если есть trivy fs?
Ответ
Trivy проверяет то, что уже лежит в репозитории, и блокирует PR. Dependabot сам предлагает обновления отдельными PR, которые проходят твой CI. Первое находит проблему, второе помогает закрыть её и не копить долг.
Что хотят услышать: «обнаружить» против «предложить исправление», регулярность, актуальные версии actions.
Красный флаг: «Dependabot это то же самое, что Trivy».
7. [junior] [на скорость] Чем режим verified в TruffleHog отличается от verified,unverified,unknown?
Ответ
TruffleHog находит строку, похожую на ключ, и проверяет запросом к провайдеру, живой ли он. verified блокирует только подтверждённо живые: почти нет шума, но выдуманный, отозванный или непроверенный ключ проходит. verified,unverified,unknown блокирует всё похожее: ничего не пропускает, зато шумит ложными срабатываниями. Статус unknown значит, что проверить не получилось (нет сети, лимиты).
Что хотят услышать: три статуса, компромисс шума и пропусков, для боя verified,unknown, для тренировочного репозитория строгий режим.
Красный флаг: «зелёный secrets значит, что секретов нет».
8. [middle] В ревью попал workflow с run: echo "${{ github.event.issue.title }}". Что не так?
Ответ
Это script injection: заголовок issue подставляется в текст скрипта до запуска shell, поэтому автор issue может выполнить команду на runner и украсть секреты. Исправляю: передаю значение через env: TITLE: ... и пишу echo "$TITLE". Дополнительно сужаю permissions.
Что хотят услышать: подстановка до shell, недоверенные поля (title, body, head_ref), передача через env, минимальные права.
Красный флаг: «issue пишут только свои, значит нормально».
9. [middle] Сторонний action в тысячах репозиториев скомпрометировали. Как ограничить ущерб заранее?
Ответ
Закрепляю actions по SHA, а не по тегу: тег можно передвинуть на другой код, SHA нет. Минимальные permissions, секреты только тем jobs, которым они нужны, окружения с ручным подтверждением для деплоя. Обновления идут через Dependabot и ревью diff. Для критичных шагов лучше свой форк или собственный скрипт.
Что хотят услышать: тег можно передвинуть, SHA нет, least privilege, ротация секретов после инцидента, поиск по организации, где использовался action.
Красный флаг: «у action много звёзд, поэтому безопасен».
10. [middle] Чем опасен pull_request_target и когда он нужен?
Ответ
Он запускает workflow из основной ветки с секретами и правом записи, а PR может прийти из форка (чужой копии репозитория). Если сделать checkout кода из PR и запустить его, чужой код получит секреты. Нужен для безобидных задач (метки, комментарии) без запуска кода из PR. Для проверки кода использую pull_request, где у форков нет секретов.
Что хотят услышать: контекст выполнения, секреты недоступны форкам в pull_request, никогда не выполнять код PR в pull_request_target.
Красный флаг: «это то же самое, что pull_request, просто новее».
11. [middle] Безопасники прислали 200 CVE из Trivy. С чего начнёшь?
Ответ
Отсекаю шум: только исправимые (ignore-unfixed), HIGH и CRITICAL, то, что реально попадает в собранный образ, а не в dev-зависимости. Смотрю, вызывается ли уязвимый код и доступен ли сервис снаружи. Группирую по пакету: часто одно обновление закрывает десятки CVE. Остальное заношу в бэклог со сроком.
Что хотят услышать: приоритизация по серьёзности, достижимости и экспозиции, группировка, автоматизация обновлений, срок пересмотра исключений.
Красный флаг: «исправлю всё подряд по порядку» или «проигнорирую, это же сканер».
12. [middle] Прод отвечает 502 после ночного мержа Dependabot. Твои действия?
Ответ
Сначала откат: возвращаю прошлую версию (revert PR или предыдущий образ), сервис снова живой. Потом смотрю логи приложения и nginx, что именно поменялось в diff зависимостей, и воспроизвожу локально. Разбираюсь, почему CI не поймал: не было теста на запуск сервиса? Добавляю проверку. Автомерж разрешаю только для патчей, которые проверены тестами.
Что хотят услышать: митигация раньше поиска причины, связь релиза и симптома по времени, разбор пробела в CI.
Красный флаг: «буду искать причину прямо на проде, откатывать не хочу».
13. [middle] Сканер выдаёт ложные срабатывания. Как с ними работать, не выключая проверку?
Ответ
Сначала проверяю, ложное ли оно на самом деле: достижим ли уязвимый код, затрагивает ли CVE нашу конфигурацию. Если да, добавляю точечное исключение в файл игнора в репозитории (для Trivy это .trivyignore, для секретов - allowlist), с идентификатором, причиной и датой пересмотра. Правка идёт через PR, чтобы её видел ревьюер. Проверку целиком не отключаю и порог не поднимаю «до никогда не падает». Если ложных много, настраиваю правила сканера, а не молчу.
Что хотят услышать: точечное исключение с причиной и сроком, ревью исключения, не отключать скан целиком.
Красный флаг: Закомментировать шаг скана, чтобы пайплайн стал зелёным.
14. [junior] Зачем pre-commit хуки, если секреты и линтеры всё равно проверяет CI?
Ответ
Хук запускается локально до коммита и даёт обратную связь за секунды: линтер, форматирование, поиск секретов (например, gitleaks) срабатывают раньше, чем ключ попадёт в историю. CI остаётся главной проверкой, потому что хук можно не установить или обойти через git commit --no-verify. Поэтому хуки ускоряют работу, а CI обеспечивает гарантию. Набор хуков удобно описать в .pre-commit-config.yaml и запускать командой pre-commit run --all-files.
Что хотят услышать: быстрая обратная связь, хуки обходятся --no-verify, CI как обязательный барьер.
Красный флаг: «Хуки есть, значит CI не нужен».
Проверено на версиях
Проверено на стенде (Ubuntu 24.04, arm64, Python 3.12.3, git 2.43.0, GNU Make 4.3) на дату 2026-09-30:
- TruffleHog v3.97.9: установка скриптом, все прогоны заданий 2 и «Сломай и почини»; режимы
verified,verified,unknown,verified,unverified,unknown, диапазон--since-commit, неполный клон. - Trivy v0.74.0: установка скриптом,
trivy fsсrequests==2.19.0и2.34.2,.trivyignore, код выхода с--exit-code 1и без него. Числа CVE зависят от базы на день запуска. - ruff 0.16.9,
make lintиmake scanс Makefile из задания 5;make testпроходит шесть тестовtest_app.pyнаapp.pyv3. - actionlint 1.7.12 (скрипт загрузки и официальный Docker-образ
rhysd/actionlint:1.7.12):ci.ymlиoidc-demo.ymlбез замечаний,inject-demo.ymlдаёт предупреждение о недоверенном значении, опечаткаconcurencyловится.check-jsonschema0.38.2:dependabot.ymlиci.ymlпроходят схему. - Разбор JWT (задание 1) и script injection (задание 4) выполнены на образце и локально.
- Версии на страницах релизов GitHub на 2026-09-30:
actions/checkoutv7.0.1,actions/setup-pythonv7.0.0,trufflesecurity/trufflehogv3.97.9,aquasecurity/trivy-actionv0.36.0 (тега v0.74.0 у него нет, это версия Trivy), ruff 0.16.9. SHA тегов полученыgit ls-remote. - Runner
ubuntu-24.04, Python 3.13 и 3.14 в matrix: это настройки workflow.
Не прогонялось: сами запуски GitHub Actions (jobs secrets, trivy-fs, oidc-demo), настоящий OIDC-токен GitHub, Dependabot, настройка required checks, gh. Синтаксис workflow проверен actionlint, dependabot.yml проверен check-jsonschema. Поведение action TruffleHog для PR (диапазон base и head) взято из его action.yml v3.97.9.
Итог урока: ты умеешь
- умею объяснить три риска CI (секреты, зависимости, сам workflow) и назвать класс инструмента для каждого
- умею добавить job TruffleHog с
fetch-depth: 0и выбрать режим статусов под задачу - умею добавить
trivy fsс порогом серьёзности и--exit-code 1и прочитать таблицу CVE - умею ограничить права через
permissionsна уровне workflow и job - умею найти и исправить script injection через переменную окружения
- умею закрепить action по SHA и настроить
dependabot.ymlи required checks наmain - умею объяснить, как OIDC заменяет статические ключи, и прочитать claims токена
- умею вести реакцию на утёкший секрет: отозвать, заменить, проверить, почистить
- умею запустить те же проверки локально (
make lint,make scan) и проверить workflow черезactionlint
Дальше: Урок 3.5: Релизы: semver, теги и GitHub Releases
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.