Заметка

DevSecOps-конвейер из 5 стадий: от secret-scan до admission policy

Пять стадий CI с exit-code 1 плюс admission-gate в кластере — единственный паттерн, при котором DevSecOps реально блокирует прод, а не работает как ритуал зелёных галочек.

Большинство «DevSecOps-пайплайнов», которые я разбирал в командах, ломаются на одном и том же месте: проверки есть, но они в режиме warn-and-continue. Сканер находит CRITICAL — pipeline зелёный. Через полгода это уже не пайплайн, а ритуал.

Чтобы security-конвейер действительно блокировал прод, нужны две вещи: пять стадий, каждая с exit-code 1 по политике, и второй gate в кластере — на admission. Один gate в CI без admission-проверки — false sense of security: CI ловит то, что собрали; кластер принимает то, что задеплоили. Эти множества пересекаются, но не совпадают.

Пять стадий, и почему именно столько

Меньше пяти — оставляешь дыру; больше — это уже не стадии, а отдельный пайплайн.

1. Secret scan       gitleaks / trufflehog / detect-secrets   fail на любом match
2. SAST              Semgrep / SonarQube / CodeQL             fail на новых HIGH/CRITICAL
3. Dependency scan   Trivy fs / Snyk / OWASP DC               fail при CVSS ≥ 7 без VEX
4. Container scan    Trivy image / Grype                      fail на HIGH/CRITICAL CVE без fix
5. Manifest / IaC    Trivy config / Checkov / Conftest        fail на security-misconfig

Логика — shift-left: чем раньше выловлен дефект, тем дешевле. Секреты ловим до того, как они попадут в историю git. SAST — до того, как код соберётся. Dependency-скан — до того, как уязвимый пакет окажется в образе. Image-скан — до push в registry. Manifest-скан — до того, как kubectl apply дойдёт до кластера.

Каждая стадия — отдельный bucket ответственности. Размывать их в «один большой scanner» — типичная ошибка: когда всё в Trivy, никто не отвечает за SAST. Когда всё в SonarQube — никто не отвечает за CVE-базы. Команды отдельные, баги — разные.

Stage 1: секреты, но не только в git

Базовый набор для git-уровня — gitleaks в pre-commit hook плюс detect-secrets с baseline в CI. Baseline нужен: без него каждый legitimate AKIA* в тесте будет ломать сборку.

Но есть второй слой, который пропускают чаще всего — секреты в слоях образа. RUN echo $TOKEN > .npmrc && ... && rm .npmrc оставляет токен в нижележащем immutable-слое; rm стирает только верхний view. docker history --no-trunc или просто docker save | strings | grep token достаёт его из registry. Лечится BuildKit-секретами:

RUN --mount=type=secret,id=npmrc cp /run/secrets/npmrc ~/.npmrc && npm ci

И в pipeline — trivy image --scanners secret поверх git-уровневого gitleaks. Два разных сканера, разные поверхности.

Stage 3–4: dependency vs container, не одно и то же

trivy fs сканирует package-lock.json, go.sum, requirements.txt — то, что в коде. trivy image сканирует собранный образ — там есть всё, что добавил базовый слой (apt-get install), и поверх — то, что положил pip install. CVE в libc базового образа trivy fs не увидит. CVE в дев-зависимости, которая в финальный образ не попадает, trivy image не покажет.

Канон в CI:

trivy image  --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed $IMAGE
trivy fs     --severity HIGH,CRITICAL --exit-code 1 .
trivy config --severity HIGH,CRITICAL --exit-code 1 ./k8s/

--ignore-unfixed — прагматика: пока апстрим не выпустил fix, блокировать сборку бесполезно. Но это создаёт .trivyignore как технический долг — каждая suppress’нутая CVE должна иметь комментарий why и JIRA-ticket. Без этого через год там будет помойка.

Stage 5 + admission: единственный надёжный паттерн

Stage 5 в CI — trivy config / Checkov / Conftest на манифесты и terraform plan -json. Ловит то, что в репозитории. Не ловит то, что задеплоили в обход CI или подменили tag — myapp:v1.2.3 в registry мог быть собран сегодня без сканов.

Поэтому второй gate — на admission:

  • Kyverno verifyImages + cosign — образ принимается, только если он подписан в CI после прохождения сканов. Сборка без подписи → admission deny.
  • OPA Gatekeeper / registry whitelist — pulling только из доверенных registries (ECR, Harbor, private GHCR). Образ из публичного docker.io/random/x отбрасывается.
  • ValidatingAdmissionPolicy (CEL) — с K8s 1.36 встроено в API server, без webhook’а. Подходит для простых политик: no :latest, no privileged, requiredLabels. Сложное (image verify, multi-resource generate) остаётся на Kyverno.

Дублирование? Намеренное. CI-scan ловит CVE в момент сборки; admission verify ловит deploy образа, обошедшего CI. Два слоя, каждый закрывает class отказов другого.

IaC: pre-apply gate как последний барьер

Terraform — отдельный риск: один terraform apply создаёт десятки ресурсов мгновенно. Pre-apply gate:

terraform plan -out plan.binary
terraform show -json plan.binary > plan.json
conftest test plan.json --policy ./policies/   # OPA-семантика на план

Rego-правила: deny cidr_blocks = ["0.0.0.0/0"] на 22/3389; deny Action: "*" в IAM; deny s3_bucket без server_side_encryption_configuration. Cheaper to fail PR, чем apply’нуть и потом revert.

Структурный страховочный слой — IAM Deny без тэга ManagedBy=Terraform на account-уровне: engineer в консоли физически не может изменить IaC-managed resource. Drift из возможности превращается в невозможность.

Что забывают подключить

  • exit-code 1 везде. «Soft-fail» в CI — это «нет CI». Sonar quality-gate в режиме failure (а не warning) — обязательно.
  • .trivyignore ревьюится в PR. Suppression без обоснования — самый дешёвый способ обезоружить весь пайплайн.
  • trivy db --skip-db-update в CI — антипаттерн. CVE-база обновляется ~3 раза в сутки; вчерашняя — это другая база.
  • Audit log на admission deny. Без него admission-gate работает молча, и команда не видит, что Kyverno вчера блокировал три деплоя.

Главный сдвиг — перестать думать о DevSecOps как о «настроить сканер в CI». Это два слоя политики на одни и те же риски: shift-left в CI плюс admission-time enforce в кластере. Один без другого — это либо ритуал зелёных галочек, либо open kubeconfig для образа, обошедшего пайплайн.