DevSecOps: встраиваем SAST в GitLab CI и смотрим что вылезло
Интегрируем статический анализ кода в пайплайн GitLab CI: SAST запускается на каждый merge request и останавливает уязвимые изменения до production.
Рост практики DevSecOps - встраивание статического анализа кода в CI/CD пайплайны на стадии merge request
Идея DevSecOps звучит красиво: «безопасность встроена в процесс разработки, а не накручена поверх». На практике за этим стоит конкретный вопрос - где именно в пайплайне поставить проверку, и что делать когда она что-то находит. Мы несколько недель назад начали встраивать SAST в GitLab CI для одного из клиентов, и первые результаты оказались поучительнее, чем ожидали.
Почему именно на merge request
Классическое место для проверки безопасности - это аудит готового продукта: пентест, код-ревью с фокусом на уязвимости, или сканирование production-окружения. Всё это работает, но поздно. К тому моменту код уже написан, прошёл ревью, задеплоен. Откатить что-то без хирургического вмешательства сложно, исправления требуют нового цикла разработки.
SAST на merge request - это другая логика. Анализатор смотрит на diff, который разработчик хочет слить в мастер, и даёт сигнал до того как код попал куда-либо. Разработчик ещё «в контексте» - он помнит что писал, понимает почему. Стоимость исправления в момент написания кратно ниже, чем после деплоя.
Дополнительный плюс: GitLab сам умеет показывать результаты SAST прямо в интерфейсе merge request начиная с версии 10.3. Инженер по безопасности не смотрит отчёт в отдельной системе - он видит флаги там же, где обсуждается код.
Что поставили и как настроили
Клиент - небольшой финтех, основной стек Python и немного Go. Для Python выбрали Bandit - простой, хорошо известный, результаты в JSON, интегрируется без плясок с бубном. Для Go подключили gas (Go AST Scanner).
В .gitlab-ci.yml добавили отдельный stage:
stages:
- test
- sast
- build
- deploy
bandit:
stage: sast
image: python:3.6
script:
- pip install bandit
- bandit -r . -f json -o bandit-report.json || true
- python -c "
import json, sys
report = json.load(open('bandit-report.json'))
highs = [i for i in report['results'] if i['issue_severity'] == 'HIGH']
if highs:
for h in highs:
print(f\"HIGH: {h['issue_text']} in {h['filename']}:{h['line_number']}\")
sys.exit(1)
"
artifacts:
paths:
- bandit-report.json
when: always
only:
- merge_requests
Важный момент - || true после Bandit. Сам Bandit возвращает ненулевой код если нашёл хоть что-то, включая LOW. Мы не хотим блокировать MR из-за каждого предупреждения уровня LOW - это верный способ получить усталость от алертов и команду которая отключила SAST «потому что мешает работать». Блокируем только HIGH, остальное пишем в артефакты для ревью.
Что вылезло в первые два дня
Честно - ожидали меньше. Несколько категорий находок повторялись:
Хардкод credentials и токенов. Не прямо в коде, но в тестовых фикстурах и в конфигах которые шли рядом с кодом. Классика - разработчик положил тестовый API-ключ в fixtures/test_config.py «временно», и это «временно» жило уже полгода. Bandit на такое реагирует через правило B105/B106 - пароли в виде строковых литералов.
Небезопасные функции десериализации. pickle.loads() в нескольких местах без какой-либо валидации источника данных. Один из сервисов принимал сериализованные объекты из очереди - теоретически только свои, на практике без проверки. Bandit B301/B302 - это HIGH.
Использование subprocess с shell=True. Несколько вызовов, где аргументы частично шли из внешнего источника. Конкретный путь эксплуатации там сомнительный, но правило правильное - shell=True с внешними данными это классический вектор.
SSL verification отключен. verify=False в нескольких requests-вызовах к внутренним сервисам. Аргумент разработчиков: «это только внутренняя сеть». Аргумент Bandit: B501, HIGH.
Примерно треть находок после разбора оказалась false positive или «это допустимо в данном контексте». Остальное - реальные вещи, которые стоило поправить.
Как договорились с командой
Первая реакция разработчиков предсказуемая: «это мешает деплоить». Особенно когда SAST заблокировал несколько MR в первые дни. Мы договорились на несколько правил:
Первое - блокирующий порог. HIGH блокирует MR, MEDIUM и LOW пишутся в отчёт но не блокируют. Разработчик должен их увидеть, но может смержить.
Второе - suppression с обоснованием. Если находка - false positive или риск принят осознанно, её можно подавить через комментарий # nosec прямо в коде, но рядом должен быть комментарий с объяснением. Мы следим за тем чтобы # nosec не превращался в способ игнорировать всё подряд - в code review это отдельная точка внимания.
Третье - grace period для старого кода. Находки в коде, написанном до внедрения SAST, не блокируют - их заносим в отдельный бэклог и разбираем планово. Иначе вся команда встаёт на неделю разгребать legacy вместо работы.
Что это даёт в контексте аудита
Мы делаем это в рамках аудита безопасности, и результат SAST - хорошее дополнение к ручному анализу. Автоматика находит паттерны быстро и без усталости, человек разбирается в контексте и архитектурных решениях. Ни то, ни другое в одиночку не даёт полной картины.
Что SAST не делает: не находит логические уязвимости в бизнес-логике, не проверяет конфигурацию инфраструктуры, не ловит проблемы авторизации и аутентификации на уровне архитектуры. Для этого нужен DAST поверх тестового окружения и ручное ревью. Но это уже следующий шаг - пока разбираемся с тем, что даёт статический анализ, и учим команду с ним работать.
Через месяц-другой посмотрим на динамику: снизится ли количество HIGH-находок по мере того как разработчики привыкают к паттернам, которые Bandit считает проблемными. Гипотеза в том что постепенно это начинает работать как образование, а не только как контроль.