Безопасность конвейера: секреты, supply chain, SAST/DAST, подпись образов
Мы прошли трек целиком: от https://courses.digitable.life/post/devops/01-ci-fundamentals/ до https://courses.digitable.life/post/devops/16-observability-and-oncall/. Каждая статья добавляла в конвейер новую способность — собирать, катить, откатывать, масштабировать, наблюдать. Побочный эффект: к финалу мы построили систему, которая имеет право писать в прод-реестр, читать прод-секреты, менять прод-кластер и делать это без участия человека. То есть — самую привилегированную систему в компании.
Атакующему больше не нужно ломать прод. Достаточно сломать то, что имеет право менять прод. Это переворачивает приоритеты: пятнадцать лет индустрия укрепляла периметр приложения, пока настоящая дверь стояла открытой в CI.
Показательна арифметика инцидента с tj-actions/changed-files (март 2025, CVE-2025-30066): злоумышленник получил доступ к популярному GitHub Action, переписал историю всех тегов на вредоносный коммит, и этот коммит начал дампить память процесса раннера в публичные логи сборки. Около 23 000 репозиториев использовали этот action. Никто из них не был взломан напрямую — все они просто написали uses: tj-actions/changed-files@v35. Одна строка @v35 вместо хеша коммита.
Эта статья — про то, как сделать конвейер не самым слабым, а самым проверяемым местом системы. С рабочими конфигами, реальными командами и честным разговором о том, сколько это стоит.
Модель угроз: против чего мы играем
Прежде чем ставить инструменты, надо понять, какие переходы вообще существуют. Артефакт в конвейере несколько раз меняет владельца, и каждая передача — точка, где его можно подменить.
Главное свойство этой картинки: контроль на границе N ничего не говорит о границе N+1. Подписанные коммиты не мешают вредоносной зависимости попасть в сборку. Сканер образа не мешает раннеру утащить секрет из окружения. Проверка подписи в кластере бесполезна, если у кого-то есть kubectl apply в обход GitOps.
base-образы, actions"] --> RUN RUN --> REG["Реестр"] REG --> CD["GitOps-контроллер"] CD --> K8S["Кластер"] A1["Кража PAT или SSH-ключа"]:::atk -.-> REPO A2["Вредоносный PR
через pull_request_target"]:::atk -.-> RUN A3["Typosquatting и
dependency confusion"]:::atk -.-> EXT A4["Компрометация
стороннего action"]:::atk -.-> RUN A5["Отравление кэша сборки"]:::atk -.-> RUN A6["Перезапись тега в реестре"]:::atk -.-> REG A7["Ручной apply мимо конвейера"]:::atk -.-> K8S classDef atk stroke:#e76f51,stroke-width:2px,stroke-dasharray:5 3
Семь классов атак, и ни один не лечится «поставить сканер». Разберём их по порядку значимости — от самого частого к самому экзотическому.
Хронология: почему это перестало быть теорией
Две вещи, которые видно только на таймлайне. Первая: центр тяжести сместился с «уязвимость в коде» на «доверенный канал доставки». Вторая: атака на xz нашлась не сканером, а разработчиком Postgres, которому показалось, что ssh стал отвечать на полсекунды медленнее. Автоматика не поймала бы её никогда — это аргумент не против автоматики, а против веры в то, что автоматика достаточна.
Секреты: почему переменная окружения — это не хранилище
Начнём с самого распространённого и самого дешёвого в исправлении.
Стандартный путь эволюции команды выглядит так, и почти все проходят его целиком:
- Секрет в коде.
DB_PASSWORD = "prod123"вsettings.py. Живёт вечно в истории git, даже после удаления. - Секрет в CI.
secrets.DB_PASSWORDв GitHub Actions. Лучше, но: он долгоживущий, виден любому workflow в репозитории, и его невозможно ротировать без ручной работы. - Секрет в хранилище. Vault, AWS Secrets Manager. Появляются аудит и ротация — но CI всё ещё нужен секрет, чтобы дотянуться до хранилища секретов. Проблема сдвинулась на уровень, а не исчезла.
- Секретов нет. CI предъявляет облаку криптографически проверяемое утверждение «я — workflow
release.ymlиз веткиmainрепозиторияacme/payments», и получает токен на 15 минут. Ротация не нужна, потому что нечего ротировать.
Четвёртый уровень называется OIDC-федерация и в 2026 году является правильным ответом по умолчанию для любой команды в публичном облаке. Он бесплатен, настраивается за час и убирает целый класс инцидентов.
OIDC-федерация: GitHub Actions без ключей AWS
Терраформ-часть — доверие к провайдеру идентичности GitHub:
# Регистрируем GitHub как OIDC-провайдера. Один раз на аккаунт.
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
# AWS с 2023 года сам валидирует цепочку доверия к публичным CA,
# но поле обязательно — оставляем известный отпечаток.
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
data "aws_iam_policy_document" "ci_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
# Аудитория — обязательно StringEquals, иначе токен из чужого сервиса подойдёт.
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# ГЛАВНАЯ строка всей конфигурации. Максимально узкий sub.
# НИКОГДА не пишите здесь repo:acme/*:* — это отдаёт роль
# любому форку и любой ветке любого репозитория организации.
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [
"repo:acme/payments:ref:refs/heads/main",
"repo:acme/payments:environment:production",
]
}
}
}
resource "aws_iam_role" "ci_deploy" {
name = "ci-deploy-payments"
assume_role_policy = data.aws_iam_policy_document.ci_trust.json
max_session_duration = 3600
}
Workflow-часть — ни одного секрета:
name: deploy
on:
push:
branches: [main]
# Права по умолчанию для всего workflow — минимальные.
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production # включает required reviewers и environment secrets
permissions:
contents: read
id-token: write # без этого OIDC-токен не выдадут
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false # не оставлять GITHUB_TOKEN в .git/config
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-deploy-payments
aws-region: eu-central-1
role-session-name: gha-${{ github.run_id }} # сессия видна в CloudTrail
- run: aws sts get-caller-identity
Вывод последнего шага в логе:
{
"UserId": "AROA...:gha-12345678901",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/ci-deploy-payments/gha-12345678901"
}
Ценность role-session-name: в CloudTrail каждое действие связывается с конкретным запуском workflow. При расследовании это разница между «кто-то из CI» и «запуск 12345678901, коммит abc123, автор Иванов».
Типичная ошибка №1 — условие sub вида repo:acme/payments:*. Оно выглядит безобидно, но означает: любой PR из форка, любая ветка, любой тег получают прод-роль. Проверить своё условие можно, распечатав реальный токен в тестовом workflow (core.getIDToken()) и посмотрев claim sub — там ровно та строка, которую надо матчить.
Типичная ошибка №2 — забыть, что environment: production без настроенных в UI reviewers не даёт защиты. Ветка main защищена — да, но workflow_dispatch из произвольной ветки может пройти по тому же sub, если он указан широко.
Когда OIDC не спасает: сравнение хранилищ
OIDC решает задачу «CI ↔ облако». Остаются секреты, у которых нет облачного провайдера идентичности: пароли к внешним API, ключи подписи, учётки к чужим SaaS. Здесь нужно хранилище.
| Вариант | Стоимость | Порог входа | Эксплуатационная нагрузка | Когда брать |
|---|---|---|---|---|
Секреты CI-платформы (secrets.*) |
0 | Минут 5 | Нулевая, но нет ротации и аудита на уровне значения | До 20 секретов, один репозиторий, нет комплаенса |
| SOPS + age в git | 0 | Полдня | Низкая: ключ расшифровки в KMS или в кластере | GitOps, малые команды, до 100 секретов |
| Облачный менеджер (AWS Secrets Manager, GCP Secret Manager) | ≈0,40 $ за секрет в месяц + плата за вызовы | Час | Низкая, ротация есть из коробки для RDS | Одно облако, 50–500 секретов |
| External Secrets Operator + облачный менеджер | То же + оператор в кластере | День | Средняя: ещё один контроллер в проде | Kubernetes, секреты нужны подам, а не только CI |
| HashiCorp Vault self-hosted | Лицензия BUSL (бесплатно для некоммерческого использования), но 1–3 узла + Raft + разблокировка | Неделя минимум | Высокая: unseal, обновления, DR, обучение | Мультиоблако, динамические креды к БД, жёсткий комплаенс |
| OpenBao | 0, форк Vault под MPL-2.0 в Linux Foundation | Неделя | Высокая, та же что у Vault | Смущает лицензия BUSL, нужен open source |
| HCP Vault Dedicated | Порядка 1 000+ $ в месяц за минимальный кластер | Часы | Низкая, оператор — HashiCorp | Vault нужен, а команды на его эксплуатацию нет |
Честный вывод, который редко звучит на конференциях: Vault для команды из десяти человек — почти всегда переусложнение. Его сильная сторона — динамические креды (Vault сам создаёт временного пользователя в PostgreSQL на час и потом удаляет) и единый интерфейс поверх разных облаков. Если ни того, ни другого не нужно, облачный менеджер плюс OIDC покрывают 90 % задач за 5 % усилий. Цифры лицензирования проверяйте: HashiCorp перевела Vault и Terraform на BUSL 1.1 в 2023-м, после чего появились форки OpenBao и OpenTofu — подробнее в https://courses.digitable.life/post/devops/09-terraform-and-iac/.
Секрет уже утёк: что делать
Утечки происходят. Важна скорость реакции, и она обеспечивается двумя слоями.
Слой первый — не дать закоммитить. pre-commit hook с gitleaks стоит десять минут настройки:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.0
hooks:
- id: gitleaks
Слой второй — найти уже закоммиченное. Полный проход по истории:
$ gitleaks detect --source . --redact --report-format json --report-path leaks.json
○
│╲
│ ○
○ ░
░ gitleaks
11:42AM INF 1847 commits scanned.
11:42AM INF scanned ~14.2 MB (git) in 3.81s
11:42AM WRN leaks found: 3
Finding: AKIA****************
Secret: AKIA****************
RuleID: aws-access-token
Entropy: 3.684
File: deploy/legacy/upload.sh
Line: 12
Commit: 8f3a1c9d4e7b2a5f6c0d9e8b7a4f3c2d1e0b9a87
Author: dev@acme.io
Date: 2023-04-11T09:22:14Z
Дальше — важный момент, который постоянно делают неправильно. Удалить строку из файла бесполезно. Секрет остался в объекте коммита 8f3a1c9d, доступен через git show, скопирован во все клоны и, если репозиторий был публичным хоть минуту, уже собран ботами (медианное время до первой попытки использования утёкшего ключа AWS исчисляется минутами, не часами).
Правильный порядок:
- Отозвать ключ. Первое действие, до любых разговоров.
aws iam delete-access-key, ротация пароля, revoke токена. - Проверить, использовался ли он: CloudTrail / audit log за период с момента коммита.
- Только потом чистить историю (
git filter-repo) — и то опционально, ценность невелика, а сломанные хеши ломают всем клоны. - Поставить блокировку, чтобы не повторилось.
Из платформенного: GitHub Secret Scanning с push protection отклоняет push с распознанным секретом на уровне сервера — для публичных репозиториев бесплатно, для приватных входит в платный Secret Protection. Дополнительно партнёрская программа означает, что при утечке AWS-ключа в публичный репозиторий GitHub уведомит AWS, и тот автоматически применит карантинную политику. Это спасало не одну компанию, но полагаться на это нельзя.
Supply chain: доверие к тому, чего вы не писали
В типичном Node-приложении около 97 % строк кода — чужие. В Go и Java доля ниже, но порядок тот же. Вопрос «безопасен ли мой код» практически неважен по сравнению с вопросом «безопасно ли то, что я потянул».
Три разных проблемы, которые путают
Известная уязвимость — в библиотеке нашли CVE. Решается обновлением, ловится SCA-сканером. Скучно, шумно, механически.
Вредоносный пакет — библиотека изначально или после угона аккаунта содержит закладку. CVE не будет, сканер не увидит. Ловится только репутационными сигналами и задержкой обновлений.
Компрометация сборки — исходники чистые, а бинарь содержит закладку (SolarWinds). Не ловится ничем, кроме воспроизводимости сборки и provenance.
Инструменты, которые продаются как «supply chain security», обычно решают только первую. Полезно понимать, что вторая и третья остаются.
Пиннинг: скучный контроль с лучшим отношением пользы к цене
# ПЛОХО: тег — это указатель, его можно переписать
- uses: tj-actions/changed-files@v35
# ХОРОШО: коммит неизменяем, комментарий сохраняет читаемость
- uses: tj-actions/changed-files@2f7c5bfce28377bc069a65ba478de0a74aa0ca32 # v46.0.1
Ровно это отличало пострадавших от непострадавших в марте 2025 года. Инструмент автоматизации — pinact или Dependabot, который умеет обновлять пины и подставлять новый комментарий с версией.
То же самое для базовых образов:
# ПЛОХО: alpine:3.20 сегодня и через месяц — разные образы
FROM alpine:3.20
# ХОРОШО: digest фиксирует содержимое побайтово
FROM alpine:3.20@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d
И для зависимостей: package-lock.json, poetry.lock, go.sum, Gemfile.lock — в репозитории, всегда, с npm ci вместо npm install в CI. Файл go.sum в Go заслуживает отдельного упоминания как лучшая реализация идеи в мейнстриме: он содержит хеши, а публичный Go Checksum Database — прозрачный лог, который делает подмену модуля задним числом обнаружимой.
Защита от dependency confusion. Атака состоит в том, что вы используете внутренний пакет acme-internal-utils, ваш менеджер пакетов настроен на два источника — приватный и публичный, — а злоумышленник публикует acme-internal-utils версии 99.0.0 в публичный PyPI. Приоритет версии побеждает приоритет источника. Лечится явным разделением:
# .npmrc — область @acme берётся только из приватного реестра
@acme:registry=https://npm.acme.io/
//npm.acme.io/:_authToken=${NPM_TOKEN}
# pip.conf — НЕ extra-index-url, а именно index-url + явный proxy,
# который сам решает, что отдавать. extra-index-url создаёт гонку версий.
[global]
index-url = https://artifactory.acme.io/api/pypi/pypi-virtual/simple
SBOM: инвентаризация, а не защита
SBOM (Software Bill of Materials) — список того, что внутри артефакта, в машиночитаемом формате: SPDX или CycloneDX. Сам по себе он ничего не защищает. Его ценность проявляется в один конкретный день: когда выходит очередной Log4Shell, и вопрос «где у нас log4j 2.14?» нужно закрыть за минуты, а не за неделю опроса команд.
# Генерация SBOM из образа
$ syft ghcr.io/acme/payments@sha256:9f2c... -o cyclonedx-json > sbom.cdx.json
✔ Parsed image
✔ Cataloged contents
├── ✔ Packages [312 packages]
├── ✔ File digests [1834 files]
└── ✔ Executables [ 42 executables]
# Сканирование этого же SBOM на известные уязвимости
$ grype sbom:sbom.cdx.json --fail-on high
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY
libcrypto3 3.3.2-r0 3.3.3-r0 apk CVE-2024-9143 Medium
golang.org/x/net 0.28.0 0.33.0 go-module CVE-2024-45338 High
Практический совет: храните SBOM рядом с образом как аттестацию, а не как артефакт сборки. Артефакты сборки живут 90 дней и теряются; аттестация в реестре живёт столько же, сколько образ, и её можно запросить для чего угодно, что сейчас бежит в проде.
SLSA: язык для разговора о зрелости
SLSA (Supply-chain Levels for Software Artifacts) — фреймворк от Google и OpenSSF, описывающий, насколько сборке можно верить. Версия 1.0 (2023) свела уровни к треку Build:
| Уровень | Что требуется | Практически |
|---|---|---|
| Build L1 | Provenance существует и доступна потребителю | Пайплайн генерирует метаданные о сборке. Защищает от ошибок, не от злоумышленника |
| Build L2 | Provenance подписана, сборка на хостируемой платформе | GitHub Actions + actions/attest-build-provenance дают это почти бесплатно |
| Build L3 | Платформа сборки укреплена, provenance неподделываема изнутри сборки | Изолированные раннеры, ключ подписи недоступен коду сборки. Требует reusable workflow или slsa-github-generator |
Прагматичный совет: L2 достижим за один рабочий день и покрывает большую часть реальных сценариев. L3 — существенно дороже и оправдан, если вы публикуете артефакты для внешних потребителей (образы, библиотеки, дистрибутивы). Гнаться за L3 для внутреннего сервиса — трата, которая почти всегда проиграет тем же деньгам, вложенным в базовую гигиену.
Подпись образов: как кластер узнаёт своё
Задача формулируется просто: кластер должен запускать только те образы, которые собрал наш конвейер из нашего кода. Тег для этого не годится — его можно перезаписать. Digest годится, но не отвечает на вопрос «а кто его сделал».
Ответ — криптографическая подпись. Классическая схема с долгоживущим приватным ключом плоха ровно тем же, чем плохи долгоживущие секреты: ключ надо где-то хранить, ротировать и защищать. Sigstore предложил обходной путь — keyless-подпись: ключ генерируется на время подписи, сертификат к нему выдаётся под OIDC-идентичность и живёт 10 минут, а факт подписи фиксируется в публичном прозрачном логе.
(GitHub) participant F as Fulcio (CA) participant R as Rekor (прозрачный лог) participant REG as Реестр participant ADM as Admission-контроллер Note over CI: cosign sign --yes образ@digest CI->>CI: сгенерировать эфемерную пару ключей CI->>OIDC: запросить ID-токен OIDC-->>CI: JWT: sub=repo:acme/payments:ref:refs/heads/main CI->>F: публичный ключ + JWT F-->>CI: сертификат X.509 на 10 минут
с identity внутри CI->>CI: подписать digest приватным ключом CI->>R: запись: подпись + сертификат R-->>CI: индекс и подписанная квитанция (SET) CI->>REG: положить подпись рядом с образом CI->>CI: УДАЛИТЬ приватный ключ Note over ADM: деплой в прод ADM->>REG: получить образ и подпись ADM->>R: проверить наличие записи ADM->>ADM: сертификат валиден на момент подписи?
identity совпадает с ожидаемой? ADM-->>ADM: допустить или отклонить Pod
Ключевой трюк в шаге 12: приватный ключ уничтожается. Украсть нечего. Проверка «был ли сертификат валиден в момент подписи» опирается на запись в Rekor — прозрачный append-only лог, подделать который незаметно нельзя.
Полный рабочий пайплайн: сборка → SBOM → скан → подпись → аттестация
name: release
on:
push:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # push в GHCR
id-token: write # OIDC для cosign и attest
attestations: write # запись provenance в GitHub
env:
IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
- uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 1. Сборка. Выход — digest, дальше работаем ТОЛЬКО с ним.
- id: build
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
with:
context: .
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
provenance: mode=max # SLSA-provenance от BuildKit
sbom: true # SBOM в манифест образа
cache-from: type=gha
cache-to: type=gha,mode=max
# 2. SBOM отдельным файлом — для хранения и для скана.
- uses: anchore/sbom-action@61119d458adab75f756bc0b9e4bde25725f86a7a # v0.17.2
with:
image: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
format: cyclonedx-json
output-file: sbom.cdx.json
# 3. Скан. Блокируем только на CRITICAL с известным фиксом —
# иначе пайплайн встанет навсегда из-за неисправимых CVE в базовом образе.
- uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # 0.30.0
with:
image-ref: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
severity: CRITICAL
ignore-unfixed: true
exit-code: '1'
format: sarif
output: trivy.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always() # результат нужен даже когда шаг упал
with:
sarif_file: trivy.sarif
# 4. Подпись keyless. Ключей в репозитории нет.
- uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- run: cosign sign --yes "${IMAGE}@${DIGEST}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
# 5. SBOM как аттестация, привязанная к digest.
- run: |
cosign attest --yes \
--predicate sbom.cdx.json \
--type cyclonedx \
"${IMAGE}@${DIGEST}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
# 6. Provenance-аттестация GitHub — SLSA Build L2.
- uses: actions/attest-build-provenance@210c1913531870065f03ce1f9440dd87bc0938cd # v2.1.0
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
Проверка снаружи, руками или в admission-контроллере:
$ cosign verify \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--certificate-identity-regexp "^https://github.com/acme/payments/\.github/workflows/release\.yml@refs/heads/main$" \
ghcr.io/acme/payments@sha256:9f2c8a1b...
Verification for ghcr.io/acme/payments@sha256:9f2c8a1b... --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
Критично: проверять надо не «есть ли подпись», а чья она. cosign verify без --certificate-identity-regexp пропустит образ, подписанный кем угодно с любым GitHub-аккаунтом — а это буквально любой человек в интернете. Это самая частая ошибка при внедрении Sigstore, и она превращает всю схему в театр.
Admission-контроль: превращаем подпись в правило
Подпись без проверки бесполезна. В Kubernetes проверку ставят на admission — подробности механики в https://courses.digitable.life/post/devops/06-kubernetes/.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signature
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 30
failurePolicy: Fail # реестр недоступен -> не пускаем, а не «пускаем на всякий случай»
rules:
- name: require-cosign-signature
match:
any:
- resources:
kinds: [Pod]
namespaces: [prod, prod-batch]
verifyImages:
- imageReferences:
- "ghcr.io/acme/*"
mutateDigest: true # заменить тег на digest в манифесте пода
required: true
attestors:
- count: 1
entries:
- keyless:
# Кто именно подписал. Без этого политика ничего не значит.
subject: "https://github.com/acme/*/.github/workflows/release.yml@refs/heads/main"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.dev
Три решения в этом манифесте, каждое из которых обсуждается на внедрении:
mutateDigest: true— Kyverno переписываетimage: app:v1.2наimage: app@sha256:.... Это закрывает гонку «проверили один образ, запустили другой после перезаписи тега».failurePolicy: Fail— при недоступности Rekor или реестра поды не создаются. Больно, но безопасно. Обратный выбор (Ignore) означает, что атакующему достаточно уронить сеть до Sigstore. Компромисс для тех, кто не готов: поднять свой Rekor или использовать offline-верификацию с bundle.webhookTimeoutSeconds: 30— проверка подписи ходит в сеть. Дефолтные 10 секунд регулярно не хватает, и вы получаете флапающий кластер.
План внедрения, который работает, а не тот, что описан в туториалах: неделю в Audit-режиме, собрать список того, что не проходит (там окажутся nginx, redis, чужие operator’ы и три забытых сервиса), составить явные исключения для внешних образов через digest-allowlist, и только потом Enforce — сначала в одном namespace. Переход сразу в Enforce на всём кластере гарантированно кладёт прод: kube-system тоже состоит из образов.
Альтернативы Kyverno: Sigstore Policy Controller (проще, только про подписи), OPA Gatekeeper (мощнее, Rego, крутая кривая обучения), Connaisseur. По совокупности порога входа и покрытия Kyverno сегодня — разумный дефолт для команд, которые не пишут на Rego.
SAST, DAST, SCA: что реально ловит и чего стоит
Здесь больше всего маркетинга и меньше всего честных цифр. Разберёмся, кто что видит.
Главный вывод из таблицы покрытия: нижние строки дороже, а верхние — обязательнее. Порядок внедрения, оптимальный по отношению пользы к затратам:
- Поиск секретов — час на настройку, мгновенная отдача, почти нет ложных срабатываний при верификации.
- SCA/SBOM — полдня, ловит реальные CVE в зависимостях, шум умеренный.
- IaC-сканирование — день, ловит открытый наружу S3 и SG с
0.0.0.0/0до того, как это станет инцидентом. - Скан образов — день, но требует дисциплины работы с базовым образом.
- SAST — неделя на настройку и месяцы на приручение шума.
- DAST — требует работающего стенда, который сам по себе задача.
Сравнение инструментов
| Инструмент | Класс | Стоимость | Порог входа | Эксплуатационная нагрузка | Замечание |
|---|---|---|---|---|---|
| gitleaks / TruffleHog | Секреты | 0 | Час | Низкая | TruffleHog умеет проверять валидность найденного ключа — резко режет шум |
| Trivy | SCA + образы + IaC + секреты | 0 | Час | Низкая | Швейцарский нож. Первый инструмент, который стоит поставить |
| grype + syft | SCA + SBOM | 0 | Час | Низкая | Разделение генерации и скана удобнее в конвейере |
| Semgrep OSS | SAST | 0 | День | Средняя | Правила читаются как код. Быстрый: секунды на средний репозиторий |
| Semgrep AppSec Platform | SAST | Порядка 40 $ за разработчика в месяц | Часы | Низкая | Дедупликация находок и diff-сканирование стоят своих денег на 30+ разработчиках |
| CodeQL | SAST | 0 для публичных репозиториев; в приватных — GitHub Code Security (порядка 30 $ за активного коммиттера в месяц) | День | Средняя | Глубокий taint-анализ, но сборка занимает минуты-десятки минут |
| SonarQube Community | SAST + качество | 0 | День | Средняя (свой сервер и БД) | Сильнее в качестве кода, чем в безопасности |
| Snyk | SCA + SAST + IaC | Бесплатный тариф с лимитами; команды — десятки долларов за разработчика | Часы | Низкая | Лучший UX, самая агрессивная модель монетизации |
| Checkov | IaC | 0 | Час | Низкая | Больше готовых правил, чем у tfsec; понимает Terraform plan |
| OWASP ZAP | DAST | 0 | День (baseline) — неделя (полный) | Средняя-высокая | Baseline-скан в PR за 2 минуты — реалистично. Полный скан — только ночью |
| Burp Suite Pro | DAST | Порядка 475 $ за пользователя в год | Часы | Ручной инструмент | Для пентестера, не для конвейера |
| Dependency-Track | Управление SBOM | 0 | Неделя | Средняя | Централизованный склад SBOM: отвечает на «где у нас эта библиотека» по всему парку |
Как встроить, чтобы не сломать поток
Ошибка, убивающая внедрение чаще всех остальных: включить блокировку на всё и сразу. На следующее утро сборка красная у всех, находок 4 000, из них настоящих 12, разработчики учатся писать # nosec и добавляют continue-on-error: true. Через месяц сканер формально работает, фактически отключён.
Работающая схема — по градиенту строгости:
заводится задача со сроком Triage --> FP: не воспроизводится FP --> Baseline: правило подавлено с комментарием Debt --> Fixed: по плану Fixed --> [*] Triage --> Blocked: CRITICAL с известным фиксом Blocked --> Fixed
Четыре правила, вытекающие из этой схемы:
Первое: diff-сканирование, а не полное. Разработчик отвечает за строки, которые он написал. Semgrep умеет --baseline-commit, Trivy — сравнение с предыдущим отчётом. Это превращает «4 000 находок» в «две, обе твои».
Второе: блокируем узко. Критерий блокировки — severity == CRITICAL && fix_available == true. Уязвимость без патча блокировать бессмысленно: она не станет менее уязвимой оттого, что вы не задеплоите фикс бага.
Третье: SLA вместо порога. Вместо «нельзя мержить с High» — «High чинится за 30 дней, Critical за 7». Это даёт разработчику предсказуемость, а безопасности — рычаг.
Четвёртое: VEX для того, что неприменимо. VEX — формат утверждений вида «CVE-2024-45338 присутствует в образе, но код не достижим, потому что мы не используем HTTP-парсер». Позволяет заглушить находку явно и с обоснованием, а не через .trivyignore без комментария.
# .trivyignore.yaml — с обоснованием и сроком, а не «просто заглушили»
vulnerabilities:
- id: CVE-2024-45338
paths:
- "usr/local/bin/app"
statement: "Уязвим только net/http/httpproxy; сервис не парсит внешние прокси-заголовки. Ревизия 2026-09-01."
expired_at: 2026-09-01
Пример для тех, кто на Jenkins
Не у всех GitHub Actions. Тот же набор проверок в декларативном пайплайне (архитектура и агенты — в https://courses.digitable.life/post/devops/02-jenkins/):
pipeline {
agent { label 'ephemeral-docker' } // одноразовый агент: секрет не переживает сборку
options { timeout(time: 30, unit: 'MINUTES') }
environment {
IMAGE = "registry.acme.io/payments"
}
stages {
stage('Секреты') {
steps {
sh 'gitleaks detect --source . --redact --exit-code 1'
}
}
stage('SCA и SAST') {
parallel {
stage('Trivy fs') {
steps { sh 'trivy fs --scanners vuln,misconfig --severity CRITICAL --exit-code 1 .' }
}
stage('Semgrep') {
steps {
// Только изменения относительно main — иначе шум неуправляем
sh 'semgrep ci --baseline-commit $(git merge-base origin/main HEAD)'
}
}
}
}
stage('Сборка и подпись') {
steps {
script {
// Креды берём из Vault на время шага, а не из глобального окружения
withVault(vaultSecrets: [[path: 'ci/registry', secretValues: [
[envVar: 'REG_USER', vaultKey: 'user'],
[envVar: 'REG_PASS', vaultKey: 'password']]]]) {
sh '''
echo "$REG_PASS" | docker login registry.acme.io -u "$REG_USER" --password-stdin
docker buildx build --push --provenance=mode=max -t "$IMAGE:$GIT_COMMIT" .
DIGEST=$(docker buildx imagetools inspect "$IMAGE:$GIT_COMMIT" --format '{{.Manifest.Digest}}')
echo "$DIGEST" > digest.txt
'''
}
// Подпись ключом из KMS: приватный ключ не покидает HSM
sh '''
cosign sign --key awskms:///alias/cosign-signing \
--yes "$IMAGE@$(cat digest.txt)"
'''
}
}
}
}
post {
always { archiveArtifacts artifacts: 'digest.txt', allowEmptyArchive: true }
cleanup { deleteDir() } // не оставлять рабочую копию с секретами на агенте
}
}
Обратите внимание на awskms:///alias/...: если keyless-схема не подходит (нет OIDC, закрытый контур), правильная альтернатива — не файл с ключом, а KMS/HSM, где приватный ключ не экспортируем в принципе.
Раннер как объект атаки
Отдельная тема, которую почти всегда упускают. Раннер — это машина, которая выполняет произвольный код из репозитория с доступом к секретам.
Self-hosted раннеры на публичных репозиториях — прямой RCE. Любой человек может открыть PR с изменённым workflow и выполнить свой код на вашей машине. GitHub прямо предупреждает об этом. Единственный корректный режим для публичного репозитория — эфемерные раннеры, которые уничтожаются после одной сборки (Actions Runner Controller в Kubernetes с ephemeral: true).
pull_request_target — заряженное ружьё. Этот триггер выполняется в контексте базовой ветки, то есть с секретами и правом записи, но по коду из форка, если вы явно сделали checkout на github.event.pull_request.head.sha. Комбинация даёт полную компрометацию. Правило: pull_request_target использовать только для операций с метаданными PR (проставить label, оставить комментарий) и никогда не чекаутить в нём код PR. Если нужно собирать код из форка — pull_request без секретов, а деплой предпросмотра выносить в отдельный workflow с workflow_run.
Отравление кэша. Кэш в GitHub Actions делится между ветками (ветка читает кэш базовой). Джоба из PR может записать в кэш вредоносный бинарь, который потом подхватит сборка main. Митигация: не кэшировать исполняемое (кэшируйте ~/.npm, а не node_modules с бинарями), и проверять целостность там, где это важно.
Права GITHUB_TOKEN. По умолчанию во многих организациях они всё ещё широкие. Установите на уровне организации read-only по умолчанию и повышайте точечно в конкретной джобе — как в примерах выше. Это одна настройка, закрывающая целый класс эскалаций.
# Минимальный набор гигиены на уровне организации / репозитория
permissions:
contents: read # дефолт для всего workflow
concurrency: # не давать конкурирующим прогонам топтать деплой
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
Экономика: сколько это стоит и что покупать первым
Разговор о безопасности упирается в бюджет. Расставим по отношению эффекта к затратам — для условной команды в 30 разработчиков.
Порядок сумм для тридцати разработчиков, в год:
| Сценарий | Инструменты | Прямые затраты | Время инженера |
|---|---|---|---|
| Минимум, который стыдно не иметь | gitleaks + Trivy + OIDC + пиннинг + permissions | 0 $ | ≈ 3 дня на внедрение, ~2 часа в месяц |
| Разумный дефолт | + cosign, Kyverno, Checkov, SBOM-аттестации | 0 $ (всё OSS) | ≈ 3 недели, ~1 день в месяц |
| Со зрелым AppSec | + Semgrep Platform, Dependency-Track, ZAP nightly | ≈ 15 000 $ | ≈ 2 месяца, 0,3 FTE постоянно |
| Комплаенс-контур | + Vault/OpenBao, SLSA L3, платный SCA, аудит | ≈ 60 000–120 000 $ | ≈ 1 FTE постоянно |
Первая строка бесплатна и закрывает большую часть реалистичных сценариев атаки на команду вашего размера. Это не отговорка «безопасность бесплатна» — это констатация, что самые дорогие инструменты покупают последние проценты, а первые семьдесят стоят три дня работы. Команда, которая купила платформу за 40 000 $, но использует uses: some-action@v3 без пина, потратила деньги на дверь при отсутствующей стене.
Стоит помнить и обратную сторону, о которой хорошо написано в https://courses.digitable.life/post/devops/15-cloud-cost-and-tradeoffs/: каждый шаг сканирования — это минуты в каждой сборке, умноженные на число сборок в день. Полный Trivy-скан крупного образа занимает 40–90 секунд; при 200 сборках в день это 3–5 часов машинного времени и заметное удлинение цикла обратной связи. Отсюда практика: тяжёлое (полный SAST, DAST, глубокий скан) — по расписанию ночью и на релизных ветках, лёгкое и diff-ориентированное — в каждом PR.
Типичные ошибки
Собранные из реальных внедрений, отсортированные по частоте.
cosign verifyбез проверки идентичности. Проверяется факт подписи, а не подписант. Защита нулевая, ощущение защищённости полное.- Широкий
subв OIDC-доверии.repo:org/*:*отдаёт прод-роль любому форку. Проверьте свои trust policy сегодня. - Тег вместо digest везде. В
uses:, вFROM, вimage:. Тег — изменяемый указатель; вся модель доверия на нём разваливается. - Сканер в блокирующем режиме с первого дня. Приводит к массовому
continue-on-errorи потере инструмента навсегда. - Секрет «удалили» из файла, но не отозвали. История git публична, ключ жив, компрометация продолжается.
.trivyignoreбез комментария и срока. Через год никто не помнит, почему заглушили; список растёт монотонно.- Self-hosted раннер, переживающий сборку. Один вредоносный PR — и на машине живёт бэкдор, видящий секреты всех последующих сборок.
failurePolicy: Ignoreв admission-политике. Атака на доступность превращается в обход контроля.- SBOM генерируется и выбрасывается. Артефакт живёт 90 дней, вопрос «где у нас log4j» приходит через полтора года.
- Безопасность как отдельная команда с правом вето. Гарантированно порождает обходные пути. Контроль должен быть в конвейере, а не в согласовании.
Мини-итог
- Конвейер — самая привилегированная система в компании. Модель угроз строится по границам доверия: разработчик → репозиторий → раннер → реестр → кластер. Контроль на одной границе не покрывает соседнюю.
- Лучшее вложение в секреты — их отсутствие. OIDC-федерация бесплатна, ставится за час и убирает долгоживущие ключи как класс. Vault оправдан динамическими кредами и мультиоблаком, а не «потому что серьёзно».
- При утечке первое действие — отзыв ключа, а не чистка истории. Удаление строки из файла ничего не меняет.
- Пиннинг по SHA и digest — самый дешёвый контроль с самым высоким эффектом. Именно его отсутствие сделало массовыми инциденты 2021 и 2025 годов.
- SBOM не защищает, а отвечает на вопросы в день инцидента. Храните его как аттестацию рядом с образом, а не как артефакт сборки.
- Keyless-подпись через Sigstore убирает проблему хранения ключа. Но проверка обязана включать идентичность подписанта — иначе это театр.
- Порядок внедрения по ROI: секреты → SCA → IaC → образы → SAST → DAST. Первые четыре бесплатны и закрывают большую часть реальных рисков.
- Блокируйте узко (
CRITICAL+ есть фикс), сканируйте по diff, управляйте через SLA, а не через пороги. Иначе инструмент будет обойдён, а не использован.
Источники
- SLSA v1.0 Specification — уровни зрелости сборки, требования к provenance.
- OpenSSF Scorecard — автоматическая оценка гигиены open source зависимостей.
- Sigstore documentation и cosign — keyless-подпись, Fulcio, Rekor.
- GitHub: Security hardening for GitHub Actions — первоисточник по
pull_request_target, permissions и раннерам. - GitHub: About security hardening with OpenID Connect — claims токена и настройка доверия.
- CISA Advisory: tj-actions/changed-files compromise (CVE-2025-30066) — разбор инцидента 2025 года.
- Alex Birsan, «Dependency Confusion» — оригинальная публикация об атаке, стоит прочитать целиком.
- Andres Freund, оригинальное письмо об xz backdoor — как это нашли на самом деле.
- OWASP Top 10 CI/CD Security Risks — десять рисков конвейера с примерами и митигациями.
- NIST SP 800-218: Secure Software Development Framework (SSDF) — то, на что ссылаются регуляторные требования.
- CycloneDX и SPDX — форматы SBOM; Dependency-Track — их централизованное хранение.
- Kyverno: Verify Images — синтаксис политик проверки подписи.
- Trivy documentation — сканер, покрывающий четыре класса проверок сразу.
- Semgrep Registry — готовые правила SAST, читаемые как код.
Что дальше
Трек по DevOps на этом закончен: от культуры и метрик в https://courses.digitable.life/post/devops/00-overview/ через сборку, доставку, оркестрацию, инфраструктуру и наблюдаемость — до конвейера, которому можно доверять. Дальше есть три разумных направления.
Вглубь эксплуатации. Если после этой статьи хочется довести до конца именно операционную часть — вернитесь к https://courses.digitable.life/post/devops/16-observability-and-oncall/ и https://courses.digitable.life/post/devops/04-cd-and-release-strategies/: безопасность конвейера и способность быстро откатиться решают одну задачу с двух сторон.
Вширь по системе. Конвейер существует ради приложения. Логичное продолжение — архитектурные паттерны, проектирование данных и базы данных, а из инженерных основ — операционные системы, без которых половина решений в контейнерах выглядит магией.
Вбок, в организацию. Многое из того, что здесь названо «ошибкой внедрения», на самом деле про людей: управление проектами и управление продуктом объясняют, почему технически верный контроль не приживается без разговора о приоритетах.
Общая карта портала и рекомендуемый порядок прохождения треков — в дорожной карте.