DevSecOps на практике: trivy + semgrep в GitLab CI вместо «потом разберёмся»
Интегрируем trivy для сканирования образов и semgrep для SAST в GitLab CI. Уязвимые базовые образы ловятся до merge в main - а не после деплоя в production.
Shift-left security: интеграция SAST/SCA в CI/CD пайплайны как стандарт DevSecOps
Словосочетание «shift-left security» звучит примерно так же, как «agile-трансформация» - то есть понятно зачем, но непонятно за что браться в первую очередь. У нас недавно закончился первый полный цикл внедрения этой логики на реальном проекте, и есть что рассказать без абстракций.
Клиент - небольшая продуктовая команда, разрабатывает веб-сервисы, использует GitLab, Kubernetes, Docker. До начала работы с нами их CI/CD пайплайн делал ровно то, для чего его писали: собирал, тестировал, деплоил. Про безопасность думали отдельно, в виде периодических ручных проверок, которые делались когда было время - то есть почти никогда.
Почему образы - это первое, с чего начинать
Если задаться вопросом «где чаще всего обнаруживаются реально эксплуатируемые уязвимости», ответ неприятный: в базовых образах. Не в коде клиента - в node:14, python:3.9, nginx:1.19. Команда за ними не следит, потому что это «не их код». А уязвимости там вполне настоящие.
Конкретный случай из этого проекта: при первом прогоне trivy на образы, которые уже несколько месяцев ходили в production, нашлись CVE с оценкой CRITICAL в базовом пакете openssl внутри python:3.9.2-slim. Образ тянулся с Docker Hub без пиннинга конкретного дайджеста, поэтому было не очень понятно, сколько времени эта уязвимость уже в production.
Trivy ставится без зависимостей, умеет сканировать образы, файловые системы и git-репозитории, выдаёт результат в нескольких форматах включая SARIF и JSON. Для GitLab CI интеграция выглядит примерно так:
container-scan:
stage: test
image:
name: aquasec/trivy:0.18.3
entrypoint: [""]
script:
- trivy image
--exit-code 1
--severity HIGH,CRITICAL
--no-progress
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
allow_failure: false
--exit-code 1 при находках HIGH/CRITICAL - пайплайн падает. Это принципиально: не предупреждение, не артефакт для просмотра, а блокировка merge. Мы намеренно не включали MEDIUM на первом этапе - иначе первый же прогон выдал бы такое количество строк, что команда просто добавила бы allow_failure: true и забыла.
После того как образы начали пинниться по дайджесту и обновляться при появлении исправленных версий, ситуация выровнялась. Теперь CRITICAL не проскакивают незамеченными, потому что пайплайн не пропустит.
SAST через semgrep: что это даёт в реальности
Со статическим анализом кода история чуть тоньше. Semgrep - это инструмент для поиска паттернов в коде через декларативные правила. В отличие от классических SAST-инструментов он не строит полный граф вызовов и не пытается отследить тейнтинг через весь стек, зато работает быстро и не требует настройки под конкретный фреймворк.
Набор правил из semgrep-rules покрывает типичные веб-уязвимости: SQL-инъекции через конкатенацию строк, eval с пользовательскими данными, хардкод секретов, использование небезопасных функций десериализации. На Python-части проекта сразу нашлись несколько мест с потенциальными проблемами - не боевые эксплойты, но паттерны, которые следовало разобрать.
Интеграция в CI:
sast:
stage: test
image: returntocorp/semgrep:0.56.0
script:
- semgrep
--config=p/security-audit
--config=p/python
--error
--metrics=off
src/
allow_failure: false
--error - аналог --exit-code 1 у trivy. --metrics=off - отключаем отправку метрик в Semgrep Cloud, у клиента это было важно с точки зрения политики.
Тут важно понимать ограничение: semgrep с набором правил p/security-audit будет давать ложноположительные срабатывания. Не десятки процентов, но регулярно. Поэтому на первых прогонах мы делали allow_failure: true, разбирали результаты совместно с командой, добавляли inline-аннотации # nosemgrep: rule-id там, где срабатывание заведомо нерелевантно. Через несколько итераций шума стало меньше, и allow_failure убрали.
Как пайплайн устроен целиком
После всех изменений CI выглядит так: сначала обычная сборка и unit-тесты, потом сборка образа, потом параллельно trivy и semgrep, потом деплой в staging. Merge в main заблокирован до прохождения всех стадий включая сканирование.
build -> unit-tests -> docker-build -> [trivy || semgrep] -> deploy:staging
Время добавилось: trivy на образ с зависимостями занимает от 30 секунд до пары минут в зависимости от размера. Semgrep на кодовую базу среднего проекта - секунд 20-40. Для команды это не критично.
Что получилось по факту
Главное наблюдение: уязвимые базовые образы теперь не доходят до production. Это не потому что их стало меньше - их стало столько же. Просто раньше они тихо приезжали в production вместе с кодом, а теперь пайплайн говорит «нет» и указывает на конкретный пакет и CVE.
Команда поначалу воспринимала это как дополнительную нагрузку - нужно следить за обновлением образов, разбирать срабатывания semgrep. Но через несколько недель это стало фоновой рутиной, а не чрезвычайной ситуацией. Как CI-тесты: сначала раздражает, что пайплайн падает, потом привыкаешь, что это нормально.
Отдельный эффект - видимость. Теперь в каждом MR есть артефакты со списком уязвимостей и их статусами. Это не отчёт «для безопасников» - это часть обычного процесса разработки. Разработчик, который открывает MR, видит результаты сканирования так же, как видит результаты тестов.
Всё это настраивалось в рамках managed-сопровождения - не как разовый проект, а как итеративное встраивание в существующий пайплайн. На следующем шаге планируем добавить dependency scanning через встроенные механизмы GitLab и посмотреть на управление секретами в CI - там тоже есть что улучшить.