В прошлой статье мы завершили настройку нашего стенда РБПО: подготовили GitLab и GitLab Runner, настроили Nexus, Vault, DefectDojo и Dependency-Track, а также создали все необходимые учетные записи, роли и переменные для будущего конвейера безопасной разработки.

Теперь настало время собрать все эти инструменты в единый пайплайн. В этой статье напишем GitLab CI/CD-конвейер, который автоматизирует сборку приложения, проверки безопасности, генерацию SBOM и публикацию результатов в настроенные ранее сервисы.

Другими словами, именно здесь наш стенд начнет выполнять ту работу, ради которой мы собирали его на протяжении предыдущих частей цикла.

Дисклеймер

Важно сразу условиться, Путник!

Данный цикл статей не заменяет требования ГОСТ, OWASP SAMM, BSIMM и других серьезных методологий безопасной разработки. Это, скорее, практический пример того, как можно организовать конвейер безопасной разработки ПО с минимальным вложением средств. 

Всё описанное в цикле — исключительно мой личный опыт, набитые шишки и результаты долгих вечеров за клавиатурой. Это не официальная позиция моего текущего или бывших работодателей. 

Воспринимать статью как руководство к бездумному копированию тоже не стоит. Проверяй всё на тестовых стендах, делай бэкапы и подходи к экспериментам с холодной головой.

Ошибки возможны, и не нужно бояться их совершать. Главное — делать выводы и постепенно строить процессы, которые будут надежнее вчерашних.


Показал я разработчикам боярам, как настраивать конвейер на языке YAML-овском. Настроили мы средства сканирования да средства хранения.

Задача перед нами стояла амбициозная: построить конвейер, который соберет приложение, проверит код на секреты, уязвимости в зависимостях и инфраструктурные косяки, проведет динамический анализ и сформирует SBOM. А дальше — упакует приложение в Docker-образ, отправит артефакты в Nexus, а отчеты и SBOM доставит в DefectDojo и Dependency-Track.

Всё это должно крутиться на двух раннерах: один — shell, второй — docker.

Чтобы не копировать одни и те же инструкции между проектами, общие джобы я вынес в отдельный репозиторий devsecops. Получилось удобно: один раз подготовил шаблон, а дальше подключай его через include там, где он нужен. 

Для получения секретов из Vault я подготовил отдельный шаблон vault-setup.yml и настроил JWT-аутентификацию GitLab. Так, необходимые для работы пайплайна секреты будут храниться централизованно в Vault и не попадут в репозитории.

В итоге получился конвейер, где каждый этап зависит от предыдущего, артефакты передаются через needs, а отчеты и результаты анализа автоматически попадают в предназначенные для них системы. Смотри да бери на вооружение!

Репозиторий devsecops состоял из файлов:

devsecops/

├── ci/

│   ├── vault-setup.yml                         # Получение секретов из Vault 

│   ├── secrets-scan.yml                      # Сканирование Gitleaks

│   ├── sast.yml                                    # Сканирование Opengrep

│   ├── iac-scan.yml                             # Сканирование Checkov

│   ├── sbom-docker.yml                      # Сбор SBOM контейнера Trivy

│   ├── sbom.yml                                  # Сбор SBOM Trivy

│   ├── dast.yml                                    # Сканирование Nuclei

│   ├── nexus-upload.yml                     # Отправка в Nexus

│   ├── defectdojo-upload.yml              # Отправка в DefectDojo 

│   └── dependency-track-upload.yml  # Отправка в Dependency-Track

Сам же конвейер располагался в репозитории с приложением и состоял из подключенных YAML-ликов репозитория devsecops и отдельно описанных стадий build и docker-build.

Если разложить весь процесс по этапам, получится следующая схема:

Рисунок 1. Схема конвейера разработки, полученного из GitLab
Рисунок 1. Схема конвейера разработки, полученного из GitLab

Файл ci/vault-setup.yml

Начал я с шаблона для работы с HashiCorp Vault. В нем определяется скрытая джоба, которая выполняет аутентификацию в Vault через JWT-токен GitLab, получает необходимые секреты и экспортирует их в переменные окружения. Все последующие джобы, которым нужны секреты, расширяют этот шаблон через extends.

Код джобы

.retrieve_vault_secrets:
  before_script:
    - echo "Получение секретов из Vault..."
    - |
      if ! command -v jq &> /dev/null; then
        echo "jq не найден. Установите jq на раннере."
        exit 1
      fi
      login_payload=$(jq -n --arg jwt "$ID_TOKEN" --arg role "$VAULT_AUTH_ROLE" '{jwt: $jwt, role: $role}')
      login_response=$(curl -s --request POST \
        --header "Content-Type: application/json" \
        --data "$login_payload" \
        "${VAULT_ADDR}/v1/auth/jwt/login")
      vault_token=$(echo "$login_response" | jq -r '.auth.client_token')
      if [ "$vault_token" = "null" ] || [ -z "$vault_token" ]; then
        echo "Ошибка аутентификации в Vault:"
        echo "$login_response" | jq .
        exit 1
      fi
      read_secret() {
        local path=$1
        local field=$2
        local response=$(curl -s --header "X-Vault-Token: $vault_token" \
          "${VAULT_ADDR}/v1/secret/data/${path}")
        echo "$response" | jq -r --arg fld "$field" '.data.data[$fld]'
      }
      export DOJO_URL=$(read_secret "defectdojo" "url")
      export DOJO_API_TOKEN=$(read_secret "defectdojo" "token")
      export DEPENDENCY_TRACK_URL=$(read_secret "dependency-track" "url")
      export DEPENDENCY_TRACK_API_KEY=$(read_secret "dependency-track" "api_key")
      export NEXUS_REGISTRY=$(read_secret "nexus" "registry")
      export NEXUS_USERNAME=$(read_secret "nexus" "username")
      export NEXUS_PASSWORD=$(read_secret "nexus" "password")
    - echo "Все секреты успешно загружены из Vault"

Логика джобы

Первым делом проверяем, есть ли на раннере утилита jq — без нее нам JSON не распарсить, как без ложки щи не хлебают. Если утилита отсутствует, джоба завершается с ошибкой. 

Далее используем ID_TOKEN, который GitLab генерирует для джобы. Мы формируем из него JSON-пейлоад с ролью ci-role и отправляем в Vault на эндпоинт /v1/auth/jwt/login. Vault проверяет подпись паспорта, если всё честно, выдает нам временный токен доступа (vault_token). Без пароля, без логина.

Далее мы объявляем функцию-помощницу. Она принимает путь к секрету (например, defectdojo) и имя поля (url), затем с помощью полученного vault_token обращается к Vault по адресу /v1/secret/data/${path} и выдергивает нужное значение. Всё честно, без лишних глаз.

Мы вызываем нашу функцию для каждого нужного секрета и экспортируем их в переменные окружения. Теперь любая джоба в нашем конвейере будет иметь доступ к $DOJO_URL, $NEXUS_PASSWORD и так далее — как будто они всегда были рядом.

Получилось:

  1. Безопасно. Секреты не хранятся в репозитории, не светятся в логах.

  2. Централизованно. Поменял пароль в Vault — и все пайплайны подхватили новое значение, ничего не надо править в коде или настройках GitLab.

  3. Гибко. Меняешь роль или политику в Vault — и доступ к секретам можно точечно разрешать или запрещать для разных проектов.

Файл ci/secrets-scan.yml

Этот шаблон запускает Gitleaks, формирует JSON-отчет и сохраняет его как артефакт GitLab. Даже если будут обнаружены потенциальные секреты, выполнение джобы не прервется.

Код джобы

secrets-scan:
  stage: code-scan
  tags:
    - shell
    - linux
    - local
  script:
    - echo "Secrets scan (gitleaks)"
    - gitleaks version
    - gitleaks detect --source . --report-format json --report-path gitleaks-report.json --exit-code 0
    - echo "Secrets scan завершен, отчет сохранен"
  artifacts:
    paths:
      - gitleaks-report.json
    expire_in: 1 week
    when: always

Логика джобы

Мы говорим GitLab CI: «Отдай эту джобу тому раннеру, у которого есть все три тега shell, linux и local». В нашем случае это shell-раннер, который крутится прямо на пятой виртуалке (sec-vm), где установлены все инструменты. Мы не используем тут Docker-раннер, потому что Gitleaks уже установлен на самой ВМ.

Сначала просто выводим версию Gitleaks и убеждаемся, что инструмент доступен на раннере. Если команда упадет — пайплайн остановится и скажет: «Нет инструмента, иди настраивай».

Далее команда gitleaks detect сканирует весь код в текущей директории (--source .), ищет потенциальные секреты и сохраняет результаты в файл gitleaks-report.json. 

Ключевая хитрость — параметр --exit-code 0. Обычно Gitleaks возвращает код ошибки (не ноль), если нашел секреты, и тогда пайплайн бы упал, а мы не хотим, чтобы он падал при первых же находках. Почему? Потому что мы всё равно отправим отчет в DefectDojo, там увидим проблемы и решим, что с ними делать. Останавливать пайплайн из-за найденного тестового ключа — слишком сурово. Поэтому мы говорим: «Нашел — не плачь, просто сохрани отчет и иди дальше», а уже человек разберется.

Далее мы говорим GitLab: «Сохрани файл gitleaks-report.json как результат работы джобы». Он будет доступен для скачивания из интерфейса, а главное — его смогут использовать последующие джобы (например, defectdojo-upload), которые импортируют этот отчет.

Параметр expire_in: 1 week ограничивает срок хранения отчета одной неделей, а when: always гарантирует сохранение артефакта независимо от результата выполнения джобы.

Файл ci/sast.yml

Следующая джоба — sast. Это уже не просто поиск паролей, а настоящий анализ исходного кода на уязвимости. Представь, что у нас есть опытный ревьюер, который пробегает глазами каждую строчку кода и ищет потенциальные дыры: XSS, SQL-инъекции, опасные функции, неправильную обработку ввода. В нашем случае эту роль играет Opengrep — форк популярного Semgrep, только с более активным сообществом и свежими правилами.

Код джобы

sast:
  stage: code-scan
  tags:
    - shell
    - linux
    - local
  script:
    - echo "SAST (opengrep) с правилами из /opt/opengrep-rules"
    - if [ ! -d /opt/opengrep-rules-clean ]; then echo "Директория правил /opt/opengrep-rules не найдена"; exit 1; fi
    - opengrep scan --config /opt/opengrep-rules-clean --json -o opengrep-report.json . || true
    - echo "SAST завершен, отчет сохранен"
  artifacts:
    paths:
      - opengrep-report.json
    expire_in: 1 week
    when: always

Логика джобы

Та же история: джоба бежит на нашем shell-раннере, который установлен на sec-vm. Раннер с тегом shell имеет прямой доступ к файловой системе и всем установленным инструментам, что нам и нужно.

«Opengrep без правил — как меч без клинка!». Мы заранее клонировали официальный репозиторий правил Opengrep в /opt/opengrep-rules и почистили его от лишних файлов (git, CI-скриптов), переименовав в opengrep-rules-clean. Здесь мы проверяем, что эта директория существует. Если нет — пайплайн падает с ошибкой, потому что сканировать нечем.

Запускаем Opengrep:

  1. scan — режим сканирования.

  2. --config /opt/opengrep-rules-clean — указываем папку с правилами. Opengrep прочитает все yaml-файлы с правилами и применит их к нашему коду.

  3. --json — выводим результат в формате JSON (чтобы потом удобно импортировать в DefectDojo).

  4. -o opengrep-report.json — сохраняем отчет в файл.

  5. . — сканируем текущую директорию (весь репозиторий).

  6. || true — это магия. Opengrep, как и Gitleaks, может вернуть ненулевой код при находках. Мы не хотим, чтобы пайплайн падал из-за найденных уязвимостей (они всё равно улетят в DefectDojo для анализа). Поэтому || true означает: «если команда упадет, не обращай внимания, считай, что успех». Так мы гарантируем, что джоба всегда завершается зеленым, а отчеты сохраняются.

Сохраняем JSON-отчет, чтобы последующая джоба defectdojo могла его подхватить и импортировать в DefectDojo. Через неделю отчет удалится, чтобы не захламлять хранилище GitLab.

Что ищет Opengrep

В наборе правил opengrep-rules-clean в числе прочих есть проверки на:

  1. XSS (например, вставка непроверенного пользовательского ввода);

  2. потенциально опасные функции (eval, exec);

  3. конструкции которые могут позволить выполнить SQL инъекцию;

  4. утечку информации через логи;

  5. небезопасные настройки файлов конфигураций IaC.

Важно помнить, что иногда SAST бывает излишне подозрительным. Например, может ругнуться на вызов eval(), если ты используешь его для безопасного JSON.parse. Не пугайся. Ложные срабатывания — это нормально. В DefectDojo их можно пометить как false positive, а со временем — добавить исключения в конфигурацию Opengrep, чтобы не шумели по пустякам.

Файл ci/iac-scan.yml

Следующая джоба проверяет на безопасность не сам код приложения, а код твоей инфраструктуры (Infrastructure as Code, IaC). Ведь что толку от безопасного React-приложения, если у тебя Dockerfile, Kubernetes-манифесты или Terraform-скрипты дырявые, как решето? 

Для проверки используется Checkov — инструмент статического анализа IaC-конфигураций, позволяющий выявлять небезопасные настройки еще до развертывания инфраструктуры. Это наш инспектор стройки, который ищет дыры в проекте дома еще до того, как мы зальем фундамент.

Код джобы

iac-scan:
  stage: code-scan
  tags:
    - shell
    - linux
    - local
  script:
    - echo "IaC scan (checkov)"
    - checkov --version
    - checkov -d . --soft-fail -o json > checkov-report.json || true
    - echo "IaC scan завершен, отчет сохранен"
  artifacts:
    paths:
      - checkov-report.json
    expire_in: 1 week
    when: always

Логика джобы

Checkov не нужно ждать сборку или тесты, он работает прямо с исходниками IaC-конфигураций.

Первым делом выводим версию Checkov и убеждаемся, что инструмент вообще доступен на раннере. Если нет — пайплайн упадет, и мы будем знать, что забыли накатить инструмент.

Сердце джобы. Команда checkov -d . сканирует текущую директорию (.), рекурсивно ищет все поддерживаемые IaC-файлы: Terraform, CloudFormation, Kubernetes, Dockerfile, ARM, Ansible и другие.

Далее --soft-fail — это как у Gitleaks --exit-code 0: говорит Checkov не возвращать код ошибки, если найдутся нарушения. Без этого флага пайплайн упал бы при первом же невыключенном debug-порте. Нам же нужны отчеты, а не красная лента в GitLab.

Параметр -o json формирует отчет в формате JSON, чтобы потом его сожрал DefectDojo. Далее > checkov-report.json перенаправляет вывод в файл.

Ну и, конечно же, || true — дополнительная страховка на случай, если Checkov вдруг ругнется на что-то еще (например, не сможет распарсить файл). С ней джоба никогда не упадет, а отчет (пустой или с ошибкой) всё равно сохранится.

Сохраняем JSON-отчет для последующей отправки в DefectDojo. Через неделю самоуничтожится, чтобы не плодить мусор.

Что именно проверяет Checkov

Checkhov, как опытный прораб, ищет типичные косяки:

  1. Dockerfile — не запущен ли контейнер от root? Не забыли ли про USER nobody? Есть ли COPY вместо ADD (без лишних магических распаковок)? Используются ли неподписанные образы?

  2. Kubernetes — не выставлены ли privileged: true? Не забыты ли readOnlyRootFilesystem и allowPrivilegeEscalation? Есть ли ограничения на ресурсы?

  3. Terraform — не выставлен ли публичный доступ к S3-бакету? Не открыт ли SSH-порт в security group для всех (0.0.0.0/0)? Шифруются ли диски? Есть ли логирование?

  4. Ansible — не используются ли незащищенные протоколы (http вместо https)? Не хранятся ли пароли в plaintext?

У читателя может возникнуть вопрос: зачем нам IaC-скан, если мы пишем React-приложение?

Очень правильный вопрос! React-приложение само по себе в общем — это статика, которую мы заливаем на сервер и транслируем через веб-сервер, например, Nginx. Но у нас есть Dockerfile (как собирать образ) и, возможно, docker-compose для локальной разработки. А если в будущем стартап разрастется, появятся Kubernetes-манифесты, Terraform-скрипты для облака. Checkov пригодится уже сейчас — привычку проверять инфраструктурный код лучше вырабатывать с самого начала, а не тогда, когда инфраструктура уже успела разрастись. 

Даже в простом Dockerfile Checkov может найти:

  1. FROM с тегом latest — нефиксированная версия, сегодня собралось, завтра сломается.

  2. Отсутствие USER — контейнер работает от root.

  3. Использование curl | bash в RUN — небезопасно, нет проверки подписи.

Файл ci/sbom.yml

SBOM (Software Bill of Materials) — это перечень библиотек, конкретных файлов и зависимостей, имеющих отношение к разрабатываемому ПО. SBOM включает сторонние библиотеки, программные пакеты, а также собственные артефакты, создаваемые командой разработки.

Код джобы

sbom:
 stage: sbom
 tags:
   - shell
   - linux
   - local
 needs: ["build"]
 script:
   - echo "Генерация SBOM в формате CycloneDX"
   - trivy fs --format cyclonedx --output sbom.json .
   - echo "SBOM сохранен в sbom.json"
 artifacts:
   paths:
     - sbom.json
   expire_in: 1 week
   when: always

Логика джобы

Как и предыдущие проверки, эта джоба выполняется на shell-раннере, установленном на виртуальной машине sec-vm. Trivy уже установлен и готов к работе.

Обратите внимание на зависимость needs: ["build"]. Джоба sbom как бы говорит: «Не запускай меня, пока build не соберет исходники (артефакты)». А build у нас занимается установкой npm-зависимостей и сборкой React-приложения. После этого в директории появляется node_modules и другие файлы, в соответствии с которыми строится SBOM (например package-lock.json). Если бы мы запустили sbom раньше, то node_modules могло бы еще не существовать, и опись была бы неполной.

Сердце джобы — команда trivy fs, сканирование файловой системы (filesystem). В отличие от trivy image, которое работает с контейнером, trivy fs анализирует все файлы в указанной директории (у нас это . — корень репозитория).

--format cyclonedx — тот же стандарт CycloneDX, что и в sbom-docker. Dependency-Track одинаково хорошо понимает и SBOM из файлов, и SBOM из образа.

Затем следует --output sbom.json — сохраняем результат.

Напоследок . — сканируем текущую папку, где лежат package.json, package-lock.json, а также другие возможные манифесты (например, requirements.txt для Python, но у нас React, так что только npm).

Trivy пройдется по всем зависимостям, которые указаны в package.json и реально присутствуют в node_modules (благодаря lock-файлу он точно знает, какие версии установлены). В результате получится список всех npm-пакетов с их версиями — это и есть SBOM.

Файл ci/sbom-docker.yml

В предыдущей джобе мы формировали SBOM на основе исходного кода и зависимостей проекта. Теперь сделаем то же самое для собранного Docker-образа.

Если SBOM из исходников показывает, какие компоненты использует приложение, то SBOM образа позволяет увидеть, что в итоге попало в контейнер после сборки. И если в какой-то из библиотек или компоненте найдется уязвимость, мы будем точно знать, какой образ и в каком проекте нужно срочно чинить. Dependency-Track — наш архивный свиток — будет хранить эти описи и отслеживать новые дыры.

Код джобы

sbom-docker:
  stage: sbom
  tags:
    - shell
    - linux
    - local
  needs: ["docker-build"]
  script:
    - echo "Генерация SBOM из Docker-образа"
    - IMAGE_NAME=$(head -1 image_tag.txt)   
    - echo "Сканируем образ $IMAGE_NAME"
    - trivy image --format cyclonedx --output sbom-image.json "$IMAGE_NAME"
    - echo "SBOM из образа сохранен в sbom-image.json"
  artifacts:
    paths:
      - sbom-image.json
    expire_in: 1 week
    when: always

Логика джобы

Снова наш верный shell-раннер на sec-vm. Там уже установлен Trivy, который умеет генерировать SBOM из образов. Docker тоже там есть — мы же собирали образ на той же машине.

С помощью needs: ["docker-build"] устанавливаем зависимость и говорим GitLab: «Не запускай эту джобу, пока не закончится docker-build». Как только образ собран и сохранен (в артефактах docker-build лежит файл image_tag.txt с именем образа), можно приступать к его описи.

Главная команда: trivy image — сканируем уже собранный образ (не исходники, не файловую систему, а именно контейнер).

Далее --format cyclonedx — формат SBOM по стандарту CycloneDX. Это такой же JSON, но с определенной структурой, которую понимает Dependency-Track.

Затем --output sbom-image.json — сохраняем результат в файл.

"$IMAGE_NAME" — имя образа, который мы только что собрали.

Trivy заглянет внутрь контейнера, найдет все установленные пакеты, библиотеки Node.js (если они лежат внутри), а иногда и другие артефакты. Всё это превратится в красивый список компонентов с версиями.

Сохраняем этот SBOM-файл, чтобы следующая джоба могла отправить его в Dependency-Track. Через неделю файл удалится, но Dependency-Track уже сохранит его внутри себя.

Зачем нам отдельно SBOM из образа, если у нас уже есть sbom из файловой системы? Отличный вопрос! У нас в конвейере есть две джобы:

  1. sbom (обычная) — генерирует SBOM из файлов исходного кода (trivy fs .). Она видит только зависимости, перечисленные в package.json.

  2. sbom-docker — генерирует SBOM из готового образа, а образ может содержать не только npm-зависимости, но и системные пакеты (например, nginx, curl, openssl), которые были установлены в Dockerfile через apt-get. А также в нем могут быть артефакты сборки, оставшиеся от этапа builder.

В Dependency-Track мы загружаем оба SBOM. Тогда Dependency-Track покажет полную картину: уязвимости в npm-пакетах и уязвимости в системных библиотеках контейнера. Если, скажем, в nginx:alpine обнаружат новую CVE, система сразу скажет: «Ваш образ, собранный из такого-то коммита, содержит уязвимый nginx». Без SBOM из образа мы бы об этом не узнали.

Файл ci/dast.yml

А вот и он — динамический анализ! Тут мы не просто смотрим на код, а запускаем наше приложение живьем и пытаемся его взломать, как заправский хакер, только легально и с отчетом. Инструмент — Nuclei, настоящий монстр среди сканеров уязвимостей. Он тычет в приложение тысячами готовых шаблонов: XSS, SQLi, открытые панели, неправильные заголовки — всё, что можно найти через HTTP.

dast идет после docker-build, потому что нужен живой контейнер. Без собранного образа — никуда. Ждем, пока docker-build закончится и сохранит артефакт image_tag.txt с именем образа. 

Джоба выполняется на всё том же shell-раннере на sec-vm. Там у нас и Docker, и Nuclei, и всё, что нужно для запуска контейнера и сканирования.

dast:
  stage: code-scan
  tags:
    - shell
    - linux
    - local
  needs: ["docker-build"]
  script:
    - echo "DAST запуск Nuclei на собранном приложении"
    - IMAGE_NAME=$(head -1 image_tag.txt)
    - CONTAINER_NAME="app-${CI_PROJECT_NAME}-${CI_COMMIT_SHORT_SHA}"
    - echo "Используем образ $IMAGE_NAME, контейнер $CONTAINER_NAME"
    - docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
    - docker run -d --name "$CONTAINER_NAME" -p 3000:80 "$IMAGE_NAME"
    - sleep 5
    - echo "Логи контейнера после запуска:"
    - docker logs "$CONTAINER_NAME" || true
    - echo "Ожидаем ответ от приложения на порту 3000 (до 30 секунд)..."
    - |
      timeout 30 sh -c "until curl -s -f http://localhost:3000; do sleep 2; done" || {
        echo "Приложение не запустилось в течение 30 секунд"
        echo "Последние логи контейнера:"
        docker logs "$CONTAINER_NAME" || true
        echo "Состояние контейнера:"
        docker ps -a --filter "name=$CONTAINER_NAME"
        docker stop "$CONTAINER_NAME" || true
        docker rm "$CONTAINER_NAME" || true
        exit 1
      }
    - echo "Приложение запущено, начинаем сканирование"

    - |
      if [ -n "$AUTH_USERNAME" ] && [ -n "$AUTH_PASSWORD" ]; then
        echo "Авторизация включена (Basic Auth)"
        AUTH_BASE64=$(echo -n "${AUTH_USERNAME}:${AUTH_PASSWORD}" | base64 -w 0)
        AUTH_HEADER="-H 'Authorization: Basic $AUTH_BASE64'"
      else
        echo "Авторизация отключена"
        AUTH_HEADER=""
      fi

    - echo "Версия Nuclei:"
    - nuclei -version || true

    - eval "nuclei -u http://localhost:3000 $AUTH_HEADER -json -o nuclei-report.json" && exit_status=0 || exit_status=$?

    - |
      if [ $exit_status -ne 0 ]; then
        echo "Флаг -json не сработал, пробуем -j"
        eval "nuclei -u http://localhost:3000 $AUTH_HEADER -j -o nuclei-report.json" || eval "nuclei -u http://localhost:3000 $AUTH_HEADER -o nuclei-report.json"
      fi

    - echo "DAST завершен, отчет nuclei-report.json"
    - docker stop "$CONTAINER_NAME" || true
    - docker rm "$CONTAINER_NAME" || true
  artifacts:
    paths:
      - nuclei-report.json
    expire_in: 1 week
    when: always

Логика джобы

Первым делом читаем имя образа из файла image_tag.txt и формируем уникальное имя контейнера на основе имени проекта и хеша коммита. Это позволяет избежать конфликтов при параллельном запуске нескольких конвейеров. 

Перед запуском на всякий случай удаляем контейнер с таким же именем, если он остался от предыдущего выполнения. Потом запускаем новый в фоне (-d), пробрасываем порт 3000 хоста на порт 80 контейнера. Ждем 5 секунд, чтобы сервис успел стартануть. Самый надежный способ –— не просто sleep, а реально проверить, что приложение отвечает на http://localhost:3000. Если в течение 30 секунд не дождались — выводим логи, останавливаем и удаляем контейнер, а пайплайн падает. Никакого смысла сканировать мертвое приложение.

Если в CI-переменных заданы AUTH_USERNAME и AUTH_PASSWORD, то Nuclei будет подставлять заголовок Basic Auth. Это полезно для приложений, которые требуют авторизации.

Выводим версию, чтобы в логах было видно, какой Nuclei работает.

Запуск сканирования. nuclei -u — указываем цель. -json — выводим отчет в JSON (лучше для парсинга). Сохраняем в nuclei-report.json. Переменная exit_status ловит код возврата (Nuclei может вернуть ненулевой код, если нашел уязвимости).

Дополнительно — страховка на случай разных версий. В старых версиях Nuclei флаг -json мог не работать, а работал -j. Конструкция пробует сначала -json, если не вышло — -j, если снова не вышло — запускает без JSON (тогда вывод будет текстовый, но мы его все равно сохраним в файл, хоть и не структурированный). Главное — чтобы отчет не пропал.

Напоследок останавливаем и удаляем контейнер, чтобы не занимал ресурсы. Даже если сканирование упало, мы пытаемся это сделать (|| true). Сохраняем отчет для последующей отправки в DefectDojo. Через неделю самоуничтожится.

Что такое Nuclei и почему он?

Nuclei — это сканер уязвимостей, основанный на шаблонах. В сообществе созданы тысячи YAML-шаблонов для самых разных дыр: CVE, неправильные конфигурации, открытые .git, панели администрирования, XSS, SQLi, SSRF — список бесконечен. Nuclei очень быстрый и легковесный. Идеален для DAST в CI/CD.

Что может найти Nuclei в React-приложении?

На первый взгляд, React-приложение статическое, и динамическое сканирование не очень эффективно. Но:

  1. если у приложения есть API-эндпоинты (например, /api/users, /api/login), Nuclei найдет в них дыры;

  2. если в Nginx случайно открыт .git/ или /.env — Nuclei обнаружит;

  3. неправильные заголовки безопасности (отсутствие CSP, X-Frame-Options) — Nuclei зафиксирует;

  4. если в приложении есть формы и они отправляют данные без CSRF-токенов — Nuclei может это проверить.

Если потом возникнет необходимость более плотной проверки приложения с грамотно построенной авторизацией, лучше добавь скрипт, который сначала логинится через curl, получает сессионную куку, а потом передает ее Nuclei с параметром -H "Cookie: session=...". Но для нас и этого будет достаточно.

Файл ci/nexus-upload.yml 

Мы собрали приложение, упаковали в Docker-образ, сгенерировали SBOM, проверили его на уязвимости. Теперь настало время отправить в Nexus — хранилище артефактов разработки. Там образ будет ждать своего часа, когда его заберут для тестирования или развертывания. 

Как и былинный богатырь, образ может носить разные имена. Какие именно — зависит от результатов проверок безопасности. Для «чистых» сборок публикуется несколько стандартных тегов, а для сборок с найденными проблемами используется специальный тег :bugs.

Код джобы
nexus:
  stage: upload
  tags:
    - shell
    - linux
    - local
  needs:
    - secrets-scan
    - sast
    - iac-scan
    - dast
    - docker-build
  id_tokens:
    ID_TOKEN:
      aud: http://192.168.0.201:9090
  extends: .retrieve_vault_secrets
  script:
    - |
      echo "Анализ отчетов безопасности..."
      VULN_FOUND=false

      check_gitleaks() {
        local file="gitleaks-report.json"
        if [ -f "$file" ]; then
          if jq -e '.findings | length > 0' "$file" >/dev/null 2>&1; then
            local count=$(jq '.findings | length' "$file")
            echo "Gitleaks: найдено $count секретов"
            return 0
          else
            echo "Gitleaks: секретов не найдено"
            return 1
          fi
        else
          echo "Gitleaks: файл $file не найден"
          return 1
        fi
      }

      check_opengrep() {
        local file="opengrep-report.json"
        if [ -f "$file" ]; then
          local count=$(jq '[.results[] | select(.extra.severity == "ERROR" or .extra.severity == "WARNING")] | length' "$file" 2>/dev/null)
          if [ "$count" -gt 0 ]; then
            echo "Opengrep: найдено $count уязвимостей (ERROR/WARNING)"
            return 0
          else
            echo "Opengrep: уязвимостей не найдено"
            return 1
          fi
        else
          echo "Opengrep: файл $file не найден"
          return 1
        fi
      }

      check_checkov() {
        local file="checkov-report.json"
        if [ -f "$file" ]; then
          local count=$(jq '[.results.failed_checks[] | select(.severity == "HIGH" or .severity == "CRITICAL")] | length' "$file" 2>/dev/null)
          if [ "$count" -gt 0 ]; then
            echo "Checkov: найдено $count критичных ошибок в IaC"
            return 0
          else
            echo "Checkov: критичных ошибок не найдено"
            return 1
          fi
        else
          echo "Checkov: файл $file не найден"
          return 1
        fi
      }

      check_nuclei() {
        local file="nuclei-report.json"
        if [ -f "$file" ]; then
          local count=$(jq '[.results[] | select(.severity == "high" or .severity == "critical")] | length' "$file" 2>/dev/null)
          if [ "$count" -gt 0 ]; then
            echo "Nuclei: найдено $count высококритичных уязвимостей при DAST"
            return 0
          else
            echo "Nuclei: высококритичных уязвимостей не найдено"
            return 1
          fi
        else
          echo "Nuclei: файл $file не найден"
          return 1
        fi
      }

      check_gitleaks && VULN_FOUND=true
      check_opengrep && VULN_FOUND=true
      check_checkov && VULN_FOUND=true
      check_nuclei && VULN_FOUND=true

      echo "Формирование тегов и отправка Docker-образа в Nexus Registry"
      IMAGE_TAG=$(head -1 image_tag.txt)
      DOCKER_REPO=${NEXUS_DOCKER_REPO:-docker}
      DATE_TAG=$(date +%d%m%Y)
      TIME_TAG=$(date +%H%M)
      FULL_TAG="${CI_PROJECT_NAME}-${DATE_TAG}-${TIME_TAG}-${CI_COMMIT_SHORT_SHA}"
      FULL_TAG_BUGS="${CI_PROJECT_NAME}-${DATE_TAG}-${TIME_TAG}-${CI_COMMIT_SHORT_SHA}-BUGS"

      echo "$NEXUS_PASSWORD" | docker login -u "$NEXUS_USERNAME" --password-stdin "$NEXUS_REGISTRY"

      if [ "$VULN_FOUND" = true ]; then
        echo "Найдены уязвимости публикуем только тег :bugs"
        docker tag "$IMAGE_TAG" "$NEXUS_REGISTRY/$DOCKER_REPO/${CI_PROJECT_NAME}:$FULL_TAG_BUGS"
        docker push "$NEXUS_REGISTRY/$DOCKER_REPO/${CI_PROJECT_NAME}:$FULL_TAG_BUGS"
      else
        echo "Уязвимости не найдены публикуем обычные теги"
        docker tag "$IMAGE_TAG" "$NEXUS_REGISTRY/$DOCKER_REPO/${CI_PROJECT_NAME}:$FULL_TAG"
        docker tag "$IMAGE_TAG" "$NEXUS_REGISTRY/$DOCKER_REPO/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA}"
        docker tag "$IMAGE_TAG" "$NEXUS_REGISTRY/$DOCKER_REPO/${CI_PROJECT_NAME}:latest"

        docker push "$NEXUS_REGISTRY/$DOCKER_REPO/${CI_PROJECT_NAME}:$FULL_TAG"
        docker push "$NEXUS_REGISTRY/$DOCKER_REPO/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA}"
        docker push "$NEXUS_REGISTRY/$DOCKER_REPO/${CI_PROJECT_NAME}:latest"
      fi

      docker logout "$NEXUS_REGISTRY"
      echo "Образ успешно загружен в Nexus"

  artifacts:
    when: always
    paths:
      - image_tag.txt

Логика джобы

Всё тот же shell-раннер на sec-vm. Там и Docker установлен, и доступ к сети есть, и секреты мы уже подтянули.

Ждем, когда docker-build закончит сборку образа и сохранит артефакт image_tag.txt с именем локального образа. Без этого не взлетим.

Далее мы говорим GitLab: «Сгенерируй для этой джобы ID-токен, который будет подтверждать, что джоба — своя, из нашего GitLab, с указанным адресом aud». Этот токен потом подхватит шаблон .retrieve_vault_secrets и обменяет его на настоящие секреты из Vault.

extends: .retrieve_vault_secrets как волшебное заклинание получает все инструкции из скрытого шаблона .retrieve_vault_secrets. Выполнит before_script, который сходит в Vault, получит переменные $NEXUS_REGISTRY, $NEXUS_USERNAME, $NEXUS_PASSWORD и другие. Без этого мы бы не знали, куда и под каким паролем пушить образ.

Определяем, были ли выявлены в рамках предыдущих сканирований какие-либо дефекты безопасности.

Устанавливаем переменную-флаг для анализа отчетов, полученных сканерами на предыдущих шагах. Сначала флаг равен false. Если хоть один сканер найдет что-то серьезное — станет true.

Функция проверки на примере поиска секретов работает следующим образом. jq '.findings | length' считает количество найденных секретов. Если больше 0 — возвращаем 0 (успех), в противном случае — 1. Аналогично для других сканеров, но с разными фильтрами.

Сканер

Фильтр в jq

Что ищем

Opengrep

[.results[] | select(.extra.severity == "ERROR" or .extra.severity == "WARNING")] | length

Уязвимости уровня ERROR или WARNING. INFO‑уведомления игнорируются.

Checkov

[.results.failed_checks[] | select(.severity == "HIGH" or .severity == "CRITICAL")] | length

Ошибки HIGH/CRITICAL в IaC

Nuclei

[.results[] | select(.severity == "high" or .severity == "critical")] | length

Высококритичные дыры DAST

Далее идет магия короткого замыкания check_gitleaks && VULN_FOUND=true. В bash && означает: выполнить вторую команду, только если первая вернула 0 (истина). То есть если check_gitleaks вернул 0 (найдены секреты), то выполнится VULN_FOUND=true. Если не найдены — не выполнится, и флаг останется как был. В итоге VULN_FOUND станет true, если хотя бы одна проверка вернула истину.

Идем дальше. В image_tag.txt лежит строка вида react-app:abc1234 — это имя, под которым образ собран на раннере. Но мы создаем уникальный тег. Чтобы каждый образ имел неповторимое имя, мы добавляем дату, время и короткий хеш коммита. Например: my-app-16042025-1432-abc1234. Так удобно искать по времени сборки и не бояться перезаписать нужную версию.

Логинимся в Nexus Registry. Пароль передаем через stdin (безопаснее, чем в командной строке). Переменные $NEXUS_USERNAME и $NEXUS_PASSWORD пришли из Vault.

На всякий случай поясню: stdin (standard input) — это стандартный поток ввода, через который программа получает данные извне. В операционных системах типа Unix и Windows он считается фундаментальной абстракцией, позволяющей программе взаимодействовать с пользователем или другими процессами. Если что, данное определение я нашел на просторах интернета.

Далее обрабатываем добавление тега. Логика простая: если есть дефекты и уязвимости, то добавляем к тегу BUGS и загружаем этот тег в Nexus. Если уязвимостей и дефектов безопасности нет, то присваиваем 3 тега. 

Зачем это нужно? Допустим, есть CI/CD, который автоматически выкатывает образы в тестовое окружение. Если образ помечен тегом :bugs, можем настроить деплой так, чтобы он отправлял такие образы на отдельный стенд для усиленного тестирования, или просто предупреждал команду. Это элементарный Quality Gates на уровне артефактов и без блокирования конвейера. Можно было бы сделать градацию (:bugs-low, :bugs-high), но для начала сойдет. Стартап всё же.

Финалим тем, что делаем logout. Хорошая практика после выполнения работ разлогиниться.

Что такое Nexus Registry?

Nexus — это внутреннее хранилище артефактов разработки и внешних зависимостей.

Nexus умеет быть не только репозиторием для библиотек и зависимостей, но и Docker Registry. Мы в настройках создали docker-hosted репозиторий — сюда мы и пушим образы. Потом любой сервер, у которого есть доступ к Nexus (права и сетевая связанность), может выполнить docker pull 192.168.0.202:8081/repository/docker/my-app:latest и получить образ.

Логин и пароль от Nexus не лежат в коде, а подтягиваются из Vault через JWT-токен. Таким образом, даже если кто-то прочитает .gitlab-ci.yml, он не узнает пароль, а доступ к Vault ограничен ролью ci-role и только для чтения путей secret/data/nexus.

Почему мы используем 4 тега?

  1. latest — самый простой способ всегда иметь свежий образ. Но он опасен: если ты выкатишь на сервер latest, а через час обновишь его новым коммитом, то непонятно, какая версия сейчас работает. Поэтому используют конкретные теги.

  2. ${CI_COMMIT_SHORT_SHA} — привязка к коммиту. Можно легко понять, из какого кода собран образ.

  3. ${CI_PROJECT_NAME}-${DATE}-${TIME}-${SHA} — идеален для референсов в GitOps или для отката на любую конкретную сборку.

  4. BUGS — если есть найденные дефекты безопасности.

Возможно, вторая позиция избыточна, но я решил оставить реализацию для примера.

У нас Nexus настроен HTTP, а в продакшене должен быть на HTTPS. Не забудь добавить сертификат CA в доверенные на раннере и на серверах, которые будут тянуть образы. Для реального дела — только HTTPS.

Файл ci/defectdojo-upload.yml

Вот мы и добрались до самого главного архивариуса! Джоба defectdojo — это наш летописец, который собирает все отчеты о сканированиях и записывает их в единую книгу — DefectDojo. Здесь будут храниться секреты от Gitleaks, уязвимости кода от Opengrep, косяки инфраструктуры от Checkov и дыры из динамического анализа Nuclei. 

DefectDojo не просто складирует отчеты — он умеет их дедуплицировать, классифицировать, назначать ответственных и отслеживать исправления. Настоящий штаб безопасности.

Код джобы
defectdojo:
  stage: upload
  tags:
    - shell
    - linux
    - local
  needs:
    - secrets-scan
    - sast
    - iac-scan
    - dast
  id_tokens:
    ID_TOKEN:
      aud: http://192.168.0.201:9090
  extends: .retrieve_vault_secrets
  script:
    - echo "Отправка отчетов в DefectDojo"
    - |
      # Проверяем, что переменная DOJO_PRODUCT_ID задана
      if [ -z "$DOJO_PRODUCT_ID" ]; then
        echo "Ошибка: переменная DOJO_PRODUCT_ID не установлена в GitLab CI/CD"
        exit 1
      fi
      PRODUCT_ID="$DOJO_PRODUCT_ID"
      echo "Используем продукт с ID $PRODUCT_ID"

      ENGAGEMENT_NAME="CI Scan - ${CI_COMMIT_REF_NAME} - ${CI_COMMIT_SHORT_SHA}"
      ENGAGEMENT_DESCRIPTION="Автоматическое сканирование в GitLab CI\n Ветка: ${CI_COMMIT_REF_NAME}\n Коммит: ${CI_COMMIT_SHA}\nURL: ${CI_PROJECT_URL}/-/commit/${CI_COMMIT_SHA}"
      TARGET_START=$(date +%Y-%m-%d)
      TARGET_END=$(date -d "+30 days" +%Y-%m-%d 2>/dev/null || date -v+30d +%Y-%m-%d)

      ENGAGEMENT_PAYLOAD="{\"name\":\"$ENGAGEMENT_NAME\",\"description\":\"$ENGAGEMENT_DESCRIPTION\",\"product\":$PRODUCT_ID,\"target_start\":\"$TARGET_START\",\"target_end\":\"$TARGET_END\",\"status\":\"In Progress\"}"

      ENG_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${DOJO_URL}/api/v2/engagements/" \
        -H "Authorization: Token $DOJO_API_TOKEN" \
        -H "Content-Type: application/json" \
        -d "$ENGAGEMENT_PAYLOAD")
      ENG_HTTP_CODE=$(echo "$ENG_RESPONSE" | tail -n1)
      ENG_BODY=$(echo "$ENG_RESPONSE" | head -n -1)

      if [ "$ENG_HTTP_CODE" -eq 201 ]; then
        ENGAGEMENT_ID=$(echo "$ENG_BODY" | jq -r '.id')
        echo "Engagement создан с ID: $ENGAGEMENT_ID"
      else
        echo "Ошибка создания engagement: HTTP $ENG_HTTP_CODE"
        echo "Ответ: $ENG_BODY"
        exit 1
      fi
    - |
      import_scan() {
        scan_type="$1"
        file="$2"
        if [ ! -f "$file" ]; then
          echo "Файл $file отсутствует, пропускаем $scan_type"
          return 0
        fi
        echo "Импортируем $scan_type из $file в engagement $ENGAGEMENT_ID"
        IMPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${DOJO_URL}/api/v2/import-scan/" \
          -H "Authorization: Token $DOJO_API_TOKEN" \
          -F "engagement=$ENGAGEMENT_ID" \
          -F "scan_type=$scan_type" \
          -F "file=@$file" \
          -F "close_old_findings=true" \
          -F "close_old_findings_product_scope=true")
        IMPORT_HTTP_CODE=$(echo "$IMPORT_RESPONSE" | tail -n1)
        IMPORT_BODY=$(echo "$IMPORT_RESPONSE" | head -n -1)
        if [ "$IMPORT_HTTP_CODE" -eq 200 ] || [ "$IMPORT_HTTP_CODE" -eq 201 ]; then
          echo "$scan_type успешно импортирован"
        else
          echo "Ошибка импорта $scan_type: HTTP $IMPORT_HTTP_CODE"
          echo "Ответ: $IMPORT_BODY"
          exit 1
        fi
      }
    - import_scan "Gitleaks Scan" "gitleaks-report.json"
    - import_scan "Semgrep JSON Report" "opengrep-report.json"
    - import_scan "Checkov Scan" "checkov-report.json"
    - import_scan "Nuclei Scan" "nuclei-report.json"
    - |
      echo "Закрываем engagement $ENGAGEMENT_ID"
      CLOSE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${DOJO_URL}/api/v2/engagements/${ENGAGEMENT_ID}/close/" \
        -H "Authorization: Token $DOJO_API_TOKEN" -d "")
      CLOSE_HTTP_CODE=$(echo "$CLOSE_RESPONSE" | tail -n1)
      if [ "$CLOSE_HTTP_CODE" -eq 200 ]; then
        echo "Engagement закрыт"
      else
        echo "Не удалось закрыть engagement (HTTP $CLOSE_HTTP_CODE), но это не критично"
      fi
    - echo "Все операции с DefectDojo завершены"
  artifacts:
    when: always
    paths:
      - gitleaks-report.json
      - opengrep-report.json
      - checkov-report.json
      - nuclei-report.json

Логика джобы

Как и nexus, эта джоба работает на этапе upload — самом последнем этапе конвейера. К моменту ее запуска результаты всех выполненных проверок уже сохранены в виде отчетов, которые и будут импортированы в DefectDojo. 

Всё тот же shell-раннер на sec-vm. Нам нужны curl, jq, доступ к файлам с отчетами — всё это там есть.

Джоба ждет успешного завершения четырех сканеров. Обрати внимание: sca (Trivy SCA) и sbom тут не указаны, это потому, что мы решили отправлять в DefectDojo только результаты уязвимостей (Gitleaks, Opengrep, Checkov, Nuclei), а SBOM — это не уязвимости, а опись. Для SBOM у нас отдельный получатель — Dependency-Track.

Как и в nexus, мы получаем ID-токен для аутентификации в Vault, а затем через шаблон .retrieve_vault_secrets загружаем переменные $DOJO_URL и $DOJO_API_TOKEN. Без них мы не сможем обращаться к API DefectDojo.

В DefectDojo все результаты сканирований привязываются к конкретному продукту (product). В нашем случае предполагается, что такой продукт уже создан заранее через веб-интерфейс. Используем готовый ID продукта из переменной DOJO_PRODUCT_ID. Если не нашли — пайплайн падает, потому что некуда складывать отчеты.

В DefectDojo сканирования группируются в активности (engagement) — обычно привязанные к спринту или к конкретному CI-запуску. Мы создаем новый engagement с именем, содержащим ветку и хеш коммита, датой начала (сегодня) и окончания (через 30 дней). Статус — In Progress. Получаем ENGAGEMENT_ID, куда будем складывать все отчеты.

Функция импорта import_scan() — это удобный вспомогательный блок, который принимает тип скана (например, Gitleaks Scan) и путь к файлу. Проверяет, существует ли файл, затем через POST /api/v2/import-scan/ отправляет его в DefectDojo, привязывая к созданному engagement. Флаги close_old_findings=true и close_old_findings_product_scope=true означают, что если в этом engagement (или во всем продукте) уже были такие же уязвимости, но они не проявились в новом скане — их автоматически закроют. Очень удобно для CI/CD: каждый запуск обновляет состояние уязвимостей.

Импорт всех отчетов. Последовательно вызываем функцию для каждого типа сканера. DefectDojo умеет парсить форматы:

  1. Gitleaks Scan — понимает JSON от Gitleaks.

  2. Semgrep JSON Report — подходит и для Opengrep, так как Opengrep совместим с Semgrep.

  3. Checkov Scan — родной формат Checkov.

  4. Nuclei Scan — Nuclei JSON.

Если какого-то файла нет (например, trivy-sca.json не создался, потому что не было зависимостей), функция просто пропускает его.

После того как все отчеты загружены, мы переводим engagement в статус Closed. Это сигнализирует, что сканирование завершено.

Что такое DefectDojo и зачем он нам?

DefectDojo — это менеджер уязвимостей и дефектов безопасности. Он бесплатен и умеет:

  1. принимать отчеты от десятков разных сканеров;

  2. дедуплицировать находки;

  3. назначать ответственных, статусы, критичность дефекта;

  4. строить графики и метрики;

  5. интегрироваться с Jira, Slack и другими системами.

Для стартапа DefectDojo — золотая жила. Не нужно листать десятки JSON-файлов — всё в одном месте!

Почему не импортируем SBOM в DefectDojo? Потому что SBOM — это не уязвимости, а список компонентов. Для анализа уязвимостей в зависимостях у нас есть Dependency-Track. DefectDojo хорош для агрегации результатов сканирования (SAST, DAST, IaC, секреты), а Dependency-Track — для непрерывного мониторинга библиотек. Разделение обязанностей.

Файл ci/dependency-track-upload.yml

Финальная, но от того не менее важная джоба — dependency-track. Пока DefectDojo вел учет уязвимостей, этот парень занимается анализом состава (SBOM) и отслеживанием уязвимостей в зависимостях. Если DefectDojo — это летописец, то Dependency-Track — это оружейный мастер, который проверяет, нет ли ржавчины на твоих библиотеках и не пора ли их заменить. Он принимает наши SBOM-файлы, строит граф зависимостей и в реальном времени мониторит новые CVE. Как только в какой-нибудь lodash найдется дыра — Dependency-Track сразу закричит: «Беда! Обновляйся!»

Код джобы

dependency-track:
  stage: upload
  tags:
    - shell
    - linux
    - local
  needs:
    - sbom
    - sbom-docker
  id_tokens:
    ID_TOKEN:
      aud: http://192.168.0.201:9090
  extends: .retrieve_vault_secrets
  script:
    - echo "Отправка SBOM в Dependency-Track..."
    - DT_API_URL="${DEPENDENCY_TRACK_URL%/}/api/v1/bom"
    - echo "Endpoint $DT_API_URL"

    - |
      send_sbom() {
        local file="$1"
        local project_uuid="$2"
        local name="$3"
        if [ ! -f "$file" ]; then
          echo "Файл $file отсутствует, пропускаем $name"
          return 0
        fi
        if [ -z "$project_uuid" ]; then
          echo "PROJECT_UUID для $name не задан, пропускаем"
          return 0
        fi
        echo "Отправляем $name из $file в проект $project_uuid"
        response=$(curl -s -w "\n%{http_code}" -X POST "$DT_API_URL" \
          -H "X-Api-Key: $DEPENDENCY_TRACK_API_KEY" \
          -F "project=$project_uuid" \
          -F "bom=@$file")
        http_code=$(echo "$response" | tail -n1)
        body=$(echo "$response" | head -n -1)
        echo "HTTP код ответа: $http_code"
        echo "Тело ответа: $body"
        if [ "$http_code" -eq 200 ] || [ "$http_code" -eq 201 ]; then
          echo "$name успешно загружен в Dependency-Track!"
        else
          echo "Ошибка загрузки $name: HTTP $http_code"
          exit 1
        fi
      }

    - send_sbom "sbom.json" "$DEPENDENCY_TRACK_PROJECT_UUID" "SBOM приложения"
    - send_sbom "sbom-image.json" "$DEPENDENCY_TRACK_PROJECT_DOCKER_UUID" "SBOM Docker-образа"

    - echo "Все SBOM отправлены"
  artifacts:
    when: always
    paths:
      - sbom.json
      - sbom-image.json

Логика джобы

Все тот же upload, последний рубеж. Сначала должны сгенерироваться SBOM-файлы (джобы sbom и sbom-docker), потом мы их отправим в Dependency-Track. Вместе с defectdojo и nexus эта джоба идет параллельно, но внутри нее есть свои needs

Снова наш shell-раннер на sec-vm. Здесь нужны curl и доступ к файлам SBOM.

Джоба ждет успешного завершения обеих SBOM-генераций. Без sbom.json и sbom-image.json отправлять нечего. Если какая-то из них не создалась (например, не было sbom-docker из-за ошибки сборки), то и эта джоба не запустится. Жестко, но правильно, так как без полной картины нет смысла идти в Dependency-Track.

Как и до этого момента, получаем ID-токен для Vault, а затем через шаблон .retrieve_vault_secrets загружаем переменные:

  1. $DEPENDENCY_TRACK_URL — адрес нашего Dependency-Track (например, http://192.168.0.204:8081).

  2. $DEPENDENCY_TRACK_API_KEY — API-ключ для доступа (мы его сгенерировали в этапе 3 для команды Automation).

Берем переменные из GitLab:

  1. $DEPENDENCY_TRACK_PROJECT_UUID — UUID проекта в Dependency-Track для SBOM из файлов.

  2. $DEPENDENCY_TRACK_PROJECT_DOCKER_UUID — UUID проекта для SBOM из Docker-образа.

Обрати внимание, что последние две переменные мы не создавали в Vault в явном виде — их нужно добавить в переменные проекта в GitLab. 

Функция отправки SBOM, это удобная обертка. Принимает три параметра:

  1. file — путь к SBOM-файлу (sbom.json или sbom-image.json);

  2. project_uuid — уникальный идентификатор проекта в Dependency-Track, куда нужно загрузить этот SBOM;

  3. name — просто название для логов.

Функция проверяет, существует ли файл и задан ли UUID. Если нет — пропускает (возвращает 0, не ошибка). Затем отправляет POST-запрос с API-ключом в заголовке X-Api-Key, и двумя полями формы: project (UUID) и bom (содержимое файла). Смотрит на HTTP-код: 200 или 201 — успех, иначе — всё падает.

Отправляем оба SBOM. Первый — из файлов исходного кода (npm-зависимости). Второй — из Docker-образа (системные пакеты). Оба уходят в разные проекты. После этого Dependency-Track начнет анализировать компоненты и сверять их с подключенными базами уязвимостей.

Файл .gitlab-ci.yml

До этого момента мы разбирали все части пайплайна по отдельности. Теперь перейдем к нашему «главному свитку» — файлу .gitlab-ci.yml, который объединяет описанные выше шаблоны в единый конвейер безопасной разработки.

Файл не содержит собственных джоб — они вынесены в отдельный репозиторий root1/devsecops. Однако именно .gitlab-ci.yml определяет, какие шаблоны подключать, в каком порядке выполнять этапы и как связать между собой все части конвейера.

include:
  # Всегда нужен vault-setup (скрытая джоба)
  - project: 'root1/devsecops'
    ref: main  # или конкретный тег
    file: '/ci/vault-setup.yml'

  # sbom
  - project: 'root1/devsecops'
    ref: main
    file: '/ci/sbom.yml'
  - project: 'root1/devsecops'
    ref: main
    file: '/ci/sbom-docker.yml'

  # Code scan
  - project: 'root1/devsecops'
    ref: main
    file: '/ci/secrets-scan.yml'
  - project: 'root1/devsecops'
    ref: main
    file: '/ci/sast.yml'
  - project: 'root1/devsecops'
    ref: main
    file: '/ci/iac-scan.yml'
  - project: 'root1/devsecops'
    ref: main
    file: '/ci/dast.yml' 

  # Upload (требуют секреты Vault)
  - project: 'root1/devsecops'
    ref: main
    file: '/ci/defectdojo-upload.yml'
  - project: 'root1/devsecops'
    ref: main
    file: '/ci/dependency-track-upload.yml'
  - project: 'root1/devsecops'
    ref: main
    file: '/ci/nexus-upload.yml'

stages:
  - build
  - sbom
  - code-scan
  - upload

variables:
  GIT_STRATEGY: clone

build:
  stage: build
  tags:
    - docker
    - linux
    - devsecops
  image: node:18-alpine
  script:
    - export CI=false
    - echo "Сборка React приложения"
    - npm install --cache .npm --prefer-offline
    - npm run build
    - echo "Сборка завершена, артефакты в build/"
  cache:
    key: npm
    paths:
      - .npm/
  artifacts:
    paths:
      - build/
    expire_in: 1 hour
    when: always

docker-build:
  stage: build
  tags:
    - shell
    - linux
    - local 
  needs: ["build"] 
  script:
    - echo "Сборка Docker-образа"
    - docker build -t react-app:${CI_COMMIT_SHORT_SHA} .
    - docker tag react-app:${CI_COMMIT_SHORT_SHA} 
    - echo "react-app:${CI_COMMIT_SHORT_SHA}" > image_tag.txt
    - echo "Docker-образ собран"
  artifacts:
    paths:
      - image_tag.txt
    expire_in: 1 week
    when: always

Логика конвейера

Блоком include мы говорим GitLab «Сходи в репозиторий root1/devsecops, возьми оттуда файлы из папки /ci и используй их как часть моего пайплайна». Все джобы, которые мы так долго описывали (secrets-scan, sast, sbom, defectdojo и другие), живут в отдельном репозитории, который выступает в роли общей библиотеки CI/CD-шаблонов. Это повторное использование кода — не нужно копировать одни и те же YAML в каждый проект. Один раз написали и отладили шаблоны — дальше остается только подключать их через include.

Блоки include состоят из:

  1. project: 'root1/devsecops' — путь к репозиторию с шаблонами.

  2. ref: main — берем файлы из ветки main (можно и тегом версионировать, если хочешь).

  3. file: '/ci/vault-setup.yml' — путь к конкретному файлу внутри репозитория.

Обрати внимание: vault-setup.yml — это скрытый шаблон .retrieve_vault_secrets. Его нет в списке джоб, он просто подключается, но не выполняется сам по себе, по запросу. Остальные — полноценные джобы, которые появятся в пайплайне.

Далее мы определяем этапы. Сначала идет этап сборки (build), потом этап генерации SBOM (sbom), затем этап сканирования (code-scan), и, наконец, этап публикации (upload). GitLab последовательно выполняет все джобы каждого этапа и только потом переходит к следующему этапу. Мы не начнем сканировать код, пока он не собран; не отправим отчеты в DefectDojo, пока сканирование не закончено.

Переменная GIT_STRATEGY: clone приказывает GitLab делать полный клон репозитория, это нужно для наших скриптов, которые ходят по файлам.

Джоба build выполняется на Docker-раннере (теги docker, linux, devsecops), используя официальный образ node:18-alpine. Внутри контейнера:

  1. export CI=false — хитрость для React: когда он видит переменную CI=true, он трактует любые предупреждения как ошибки и падает. Мы этого не хотим, поэтому отключаем.

  2. npm install — ставим зависимости, кэшируем .npm/, чтобы в следующий раз было быстрее.

  3. npm run build — собираем статику в папку build/.

  4. artifacts — сохраняем эту папку как артефакт. Она понадобится для docker-build (копировать файлы в образ) и, возможно, для других джоб. Артефакты живут 1 час — этого хватит, чтобы собрать образ, а потом они удалятся, экономя место.

Джоба docker-build выполняется на shell-раннере (наша sec-vm), потому что Docker там уже установлен. Выполняем:

  1. needs: ["build"] — ждет, пока build закончится и отдаст артефакт build/;

  2. docker build -t react-app:${CI_COMMIT_SHORT_SHA} . — собирает образ, используя Dockerfile в корне репозитория. Внутри Dockerfile мы копируем папку build/ (артефакт предыдущей джобы) в Nginx-образ;

  3. создаем тег по короткому хешу коммита;

  4. сохраняем имя образа в файл image_tag.txt (этот файл потом прочитает nexus-upload и sbom-docker).

Артефакт image_tag.txt живет неделю — на случай, если понадобится переотправить образ в Nexus.

Как всё это работает вместе?

После запуска конвейера GitLab последовательно выполняет четыре этапа.

На этапе build собирается приложение и формируется Docker-образ. Результаты сборки сохраняются в виде артефактов для последующих джоб.

На этапе sbom генерируются две спецификации SBOM: одна — для исходного проекта, вторая — для собранного Docker-образа.

На этапе code-scan выполняются проверки безопасности: поиск секретов, SAST-анализ, проверка инфраструктурного кода и динамическое тестирование приложения.

На завершающем этапе upload результаты работы конвейера отправляются во внешние сервисы. Отчеты о найденных проблемах загружаются в DefectDojo, SBOM — в Dependency-Track, а собранный Docker-образ публикуется в Nexus.

В результате отдельные инструменты, которые мы настраивали в предыдущих частях цикла, начинают работать как единая система безопасной разработки. Всё стройно, как в хорошо срежиссированном спектакле.

Рисунок 2. Успешно завершенный конвейер безопасной разработки
Рисунок 2. Успешно завершенный конвейер безопасной разработки

Заключение

Мы объединили настроенные ранее сервисы в единый конвейер безопасной разработки. Теперь при каждом запуске GitLab автоматически собирает приложение, получает необходимые секреты из Vault, выполняет проверки безопасности, формирует SBOM, публикует артефакты в Nexus и передает результаты анализа в DefectDojo и Dependency-Track.

Самое главное, всё это — на открытых инструментах! Получился минимальный, но вполне рабочий DevSecOps-конвейер, который можно развернуть даже в небольшой компании без выделенной команды по безопасности и многомиллионного бюджета.

В следующей статье мы «подстелим соломки». Настроим резервное копирование всех пяти виртуальных машин — чтобы твои труды не пропали при сбое диска или неосторожной команде. Напишем PowerShell-скрипт, который сам остановит ВМ, скопирует их на отдельный физический диск, запустит обратно и почистит старые бэкапы. И добавим задачу в планировщик Windows — пусть бэкапы делаются каждую ночь, пока разработчики бояре спят.


PURP — Telegram-канал, где кибербезопасность раскрывается с обеих сторон баррикад

t.me/purp_sec — инсайды и инсайты из мира этичного хакинга и бизнес-ориентированной защиты от специалистов Бастиона