Практический разбор того, как я вынес security-проверки Java-проектов из разрозненных CI/CD-скриптов в переиспользуемый Gradle convention plugin.

Вступление

Самая сложная часть Java AppSec обычно не в том, чтобы найти еще один сканер.

Сканеры у команд и так часто есть.

Есть SonarQube для анализа кода. Есть OWASP Dependency-Check для проверки зависимостей. Есть CycloneDX для SBOM. Есть JaCoCo или Kover для покрытия. Есть GitLab CI, Jenkins, TeamCity, GitHub Actions или какой-нибудь внутренний CI, который все это запускает.

И все равно процесс начинает разъезжаться.

Один сервис кладет Dependency-Check-отчеты в одну директорию. Второй генерирует только HTML. Третий правильно передает merge request metadata в SonarQube. Четвертый гоняет все как обычную branch analysis. Один проект делает SBOM только по runtime-зависимостям. Другой включает test-зависимости и получает шумный отчет. В multi-module проекте появляется исключение, кто-то копирует кусок YAML из соседнего репозитория, чуть правит, и так оно живет дальше.

В первый день это не выглядит проблемой.

Через несколько месяцев это уже нормальный такой дрейф security build-процесса.

И вот эту проблему я хотел закрыть через secure-build-gradle-plugin.

Не новым сканером.

А слоем build tooling, который стандартизирует, как существующие AppSec-инструменты подключаются к Gradle-проекту.

Проект: Secure Build Gradle Plugin

Проблема, которую я хотел убрать

Обычно DevSecOps начинается с CI/CD YAML.

Для одного Java-сервиса это нормальный старт:

script:
  - ./gradlew test
  - ./gradlew dependencyCheckAnalyze
  - ./gradlew cyclonedxBom
  - ./gradlew sonar

Все понятно. Команды видны. Pipeline запускается. Отчеты появляются.

Проблема начинается, когда такой подход размножается на десятки сервисов.

Каждый сервис постепенно становится ответственным за свою security-интеграцию. Команды копируют старые куски pipeline. Версии плагинов и сканеров расходятся. Пути отчетов расходятся. Где-то добавили SARIF, где-то забыли. Где-то SonarQube получает coverage XML, где-то нет. Где-то branch analysis, где-то pull request analysis. Где-то локально можно повторить проверку, а где-то только пушить и ждать pipeline.

Снаружи это выглядит как автоматизация.

На практике это превращается в набор чуть разных ручных интеграций.

И самое неприятное: команды начинают спорить не про риск, а про проводку инструментов.

Почему отчет не там?

Почему SonarQube не видит coverage?

Почему в одном сервисе Dependency-Check падает, а в другом только пишет отчет?

Почему multi-module проект опять особенный?

В этот момент проблема уже не в том, что “нам нужны еще tools”.

Проблема в том, что нет одного места для conventions.

Почему держать всю security-логику только в CI/CD неудобно

CI/CD хорош как общая среда выполнения.

Он дает чистый runner, логи, artifacts, gates, approvals, общий audit trail. Это все нужно.

Но CI/CD не очень хорош как единственное место, где живет вся логика security-проверок.

Если вся логика находится только в .gitlab-ci.yml или Jenkinsfile, локальная разработка становится вторичной. Разработчик пушит код не только чтобы открыть merge request, но и чтобы понять, что вообще думает security pipeline.

Это плохой feedback loop.

В идеале разработчик должен иметь возможность запустить ту же базовую проверку до merge request:

./gradlew clean securityAnalyze --no-daemon

А CI/CD должен запускать тот же workflow и собирать предсказуемые артефакты.

То есть CI/CD должен исполнять security workflow, а не каждый раз заново описывать его в каждом репозитории.

Это важное отличие.

Идея решения

Принцип был простой:

security behavior должен жить ближе к коду,
а CI/CD должен запускать тот же behavior без повторной реализации

Для Gradle это естественно ложится в convention plugin.

Gradle уже является местом, где проект описывает, как он собирается, тестируется, публикуется и какие tasks доступны. Значит, туда же можно вынести повторяемые AppSec conventions.

Не политику компании.

Не финальное risk acceptance.

Не vulnerability management.

А именно build-time слой:

  • как запускать Dependency-Check;

  • куда складывать отчеты;

  • какие форматы генерировать;

  • как делать SBOM;

  • как передавать coverage в SonarQube;

  • как различать branch и merge request analysis;

  • как жить с multi-module проектами;

  • как дать разработчику одну понятную команду.

Как подключается Gradle plugin

Проект применяет plugin один раз:

plugins {
  id "java"
  id "io.github.niki1337.securebuild.gradle-java" version "0.1.0"
}

Дальше в build-файле остаются только значения конкретного сервиса:

securityConventions {
  serviceName = "payment-api"
  sonarProjectKey = "payment-api"
  allowLocalSonar = false
}

Для multi-module проекта plugin обычно подключается на root-уровне:

plugins {
  id "io.github.niki1337.securebuild.gradle-java" version "0.1.0"
}

securityConventions {
  serviceName = "payments-platform"
  sonarProjectKey = "payments-platform"
  includedModules = ["api", "service"]
  excludedModules = ["test-fixtures"]
}

После этого меняется модель владения.

CI/CD все еще запускает проверки. Security все еще отвечает за требования и triage. Разработчики все еще чинят findings.

Но повторяемая scanner-проводка живет в build system, а не копируется из репозитория в репозиторий как устное предание.

Что происходит под капотом

Plugin не заменяет существующие инструменты.

Он подключает и стандартизирует:

  • SonarQube analysis;

  • OWASP Dependency-Check;

  • CycloneDX SBOM;

  • JaCoCo или Kover coverage;

  • Gradle single-module и multi-module behavior;

  • Git branch metadata;

  • GitLab merge request metadata.

Главная команда для разработчика:

./gradlew clean securityAnalyze --no-daemon

securityAnalyze становится нормальной точкой входа для локальной AppSec-проверки. Она может запускать тесты, coverage, SBOM generation и dependency analysis.

Если нужно разобрать отдельные части, underlying tasks никуда не прячутся:

./gradlew cyclonedxDirectBom --no-daemon
./gradlew dependencyCheckAnalyze --no-daemon
./gradlew sonarHelp --no-daemon

Для multi-module:

./gradlew dependencyCheckAggregate --no-daemon

Смысл не в том, чтобы спрятать инструменты.

Смысл в том, чтобы нормальный путь был очевидным.

Почему это лучше голого CI/CD

Можно сказать: “А зачем plugin? Можно же просто написать нормальный .gitlab-ci.yml”.

Можно.

Для одного репозитория это часто нормально.

Но на масштабе начинаются обычные проблемы:

  • пути отчетов расходятся;

  • scanner settings расходятся;

  • SonarQube metadata забывается;

  • локальный запуск отличается от pipeline;

  • multi-module проекты требуют отдельных костылей;

  • новые сервисы копируют старый boilerplate;

  • security-команда получает разные форматы артефактов;

  • разработчики не понимают, какую команду запускать до MR.

Gradle convention plugin дает одно версионируемое место для этой логики.

CI/CD остается важным, но становится execution layer:

GitLab CI / Jenkins
  запускает ./gradlew securityAnalyze
  собирает artifacts
  применяет gates

А build behavior находится ближе к проекту:

Gradle plugin
  знает tasks
  знает report paths
  знает multi-module structure
  знает SonarQube metadata conventions

В закрытых контурах это особенно полезно.

Во многих СНГ-компаниях нельзя просто взять SaaS и красиво подключить все по документации. Есть внутренний GitLab, свой Nexus, Harbor, self-hosted SonarQube, прокси, внутренние CA, ограничения на интернет, отдельные runners. В такой среде предсказуемость важнее красивой картинки в документации.

SonarQube: проблема почти правильной настройки

SonarQube легко настроить “почти правильно”.

Это как раз опасная зона.

Pipeline зеленый. Analysis ушел. В UI что-то появилось.

Но Java binaries не передались. Coverage XML не передался. Merge request metadata не передалась. В итоге результат вроде есть, но он слабее, чем должен быть.

Plugin решает эту рутину централизованно.

Он читает настройки из environment variables, Gradle properties или securityConventions:

export SONAR_HOST_URL="https://sonarqube.example.com"
export SONAR_PROJECT_KEY="payment-api"
export SONAR_TOKEN="token-value"

И готовит типовые Java SonarQube properties:

sonar.sources
sonar.tests
sonar.java.binaries
sonar.java.test.binaries
sonar.java.libraries
sonar.java.test.libraries
sonar.coverage.jacoco.xmlReportPaths
sonar.exclusions
sonar.test.exclusions
sonar.cpd.exclusions
sonar.coverage.exclusions

Для GitLab merge request pipeline он может маппить CI variables:

CI_MERGE_REQUEST_IID                  -> sonar.pullrequest.key
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME   -> sonar.pullrequest.branch
CI_MERGE_REQUEST_TARGET_BRANCH_NAME   -> sonar.pullrequest.base

Для branch pipeline задается branch analysis metadata.

Это скучная, но важная работа. И именно такую работу лучше решать один раз.

Dependency-Check: отчеты должны быть скучными

Dependency-Check полезен тогда, когда его отчеты предсказуемы.

Я не хочу, чтобы один сервис генерировал JSON, второй только HTML, третий SARIF в непонятной директории, а четвертый вообще все складывал в кастомный путь.

Plugin стандартизирует форматы:

HTML
JSON
SARIF
XML

И пишет их в:

build/reports/dependency-check

По умолчанию отключаются сетевые анализаторы, которые могут делать pipeline нестабильным в закрытой инфраструктуре:

  • OSS Index;

  • RetireJS;

  • Node audit;

  • Node package analyzer;

  • hosted suppressions;

  • CISA KEV analyzer.

В идеальном мире у всех быстрый интернет и доступ к нужным внешним источникам.

В реальном enterprise-мире часто есть прокси, firewall, mirror, внутренние registry и запрет на прямой outbound. Поэтому security build не должен случайно становиться медленным или нестабильным из-за внешнего endpoint.

Если есть внутренний mirror, можно использовать его:

DT_API_URL=https://dependency-track.example.com \
./gradlew dependencyCheckAnalyze --no-daemon

По умолчанию build не падает по CVSS:

failBuildOnCVSS = 11

Так как максимальный CVSS равен 10, это означает: отчет генерируем, но build не валим только по score.

Это осознанный выбор.

На первом этапе команде часто нужна видимость и triage. Если сразу включить жесткие gates на шумных данных, разработчики быстро начнут воспринимать security как блокер без смысла. Сначала данные, валидация и false-positive reduction. Потом уже blocking policy.

SBOM без лишнего шума

SBOM полезен только тогда, когда он описывает полезный artifact.

Если один проект включает test dependencies, а другой нет, сравнивать результаты сложно. Если root multi-module проекта является только aggregator, root-level SBOM может плохо описывать реальное приложение.

Plugin фокусируется на runtime dependencies и уменьшает лишний шум:

  • runtime dependencies включаются;

  • test dependencies не добавляются;

  • license text не встраивается;

  • serial number можно отключить;

  • лишняя metadata уменьшается.

Типовые команды:

./gradlew cyclonedxDirectBom --no-daemon
./gradlew cyclonedxBom --no-daemon

Отчеты:

build/reports/cyclonedx
build/reports/cyclonedx-direct

Для multi-module Spring Boot-проектов plugin старается найти deployable module, например модуль с bootJar, и сделать SBOM ближе к реальному приложению, а не к пустому root aggregator.

Это кажется мелочью, пока не начинаешь отправлять SBOM в Dependency-Track и разбирать, почему половина findings относится к тому, что не попадает в runtime.

Coverage wiring

Coverage нужен для нормального SonarQube analysis, но paths часто расходятся.

Plugin поддерживает:

  • JaCoCo;

  • Kover.

В режиме auto он использует Kover, если Kover уже есть, иначе подключает JaCoCo:

securityConventions {
  coverageProvider = "auto"
}

Для JaCoCo plugin:

  • использует JaCoCo 0.8.13;

  • делает jacocoTestReport зависимым от tests;

  • включает XML output;

  • передает XML path в SonarQube.

Типовой путь:

build/reports/jacoco/test/jacocoTestReport.xml

Опять же, это не rocket science. Это просто та самая повторяемая настройка, которую не хочется чинить в каждом сервисе отдельно.

Multi-module Gradle проекты

Multi-module проекты быстро показывают, насколько интеграция зрелая.

Для demo single-module проекта почти любой scanner пример выглядит красиво.

А потом приходит реальный репозиторий:

root
  api
  service
  domain
  client
  test-fixtures

И начинается: где source? где tests? где binaries? какой модуль deployable? какие модули включать в analysis? что делать с root?

Plugin определяет Java subprojects с:

java
java-library

И поддерживает фильтры:

securityConventions {
  includedModules = ["api", "service"]
  excludedModules = ["test-fixtures"]
}

В multi-module режиме он:

  • настраивает coverage по Java-модулям;

  • собирает coverage XML paths;

  • конфигурирует root-level SonarQube analysis;

  • задает module-level source/test/binary/library paths;

  • запускает aggregate Dependency-Check;

  • старается не генерировать шумный root-level SBOM;

  • использует deployable module для SBOM, когда это возможно;

  • оставляет единый root-level securityAnalyze.

Это и есть отличие между “мы прикрутили сканер” и “у нас есть build convention для реальных Java repositories”.

Как выглядит CI/CD после этого

CI/CD становится проще.

Пример GitLab CI:

security:gradle:
  image: eclipse-temurin:17
  stage: test
  variables:
    GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
  script:
    - ./gradlew clean securityAnalyze --no-daemon
  artifacts:
    when: always
    expire_in: 7 days
    paths:
      - build/reports/dependency-check/
      - build/reports/cyclonedx/
      - build/reports/cyclonedx-direct/
      - "**/build/reports/jacoco/"

SonarQube можно вынести отдельно:

sonarqube:gradle:
  image: eclipse-temurin:17
  stage: test
  script:
    - ./gradlew sonar --no-daemon
  rules:
    - if: '$SONAR_TOKEN'

Важная мысль:

CI/CD вызывает build tasks,
а не заново реализует security conventions

Pipeline становится читаемее. Артефакты становятся предсказуемее. Локальный запуск становится ближе к CI.

Что этот plugin не пытается решить

Plugin специально ограничен.

Он не заменяет:

  • SonarQube;

  • OWASP Dependency-Check;

  • CycloneDX;

  • DefectDojo или Dependency-Track;

  • ручной vulnerability triage;

  • risk acceptance;

  • secret scanning;

  • DAST;

  • container scanning;

  • IaC scanning;

  • release approval process.

Это не “весь DevSecOps в одной зависимости”.

Это build-time слой для Java AppSec:

SCA
SBOM
coverage
SonarQube metadata
local and CI/CD behavior
report conventions

Secret scanning я сознательно отношу к более раннему слою: до commit и push. Про это лучше писать отдельно, потому что там другая логика и другой feedback loop.

Итог

secure-build-gradle-plugin решает не проблему “нет сканеров”.

Он решает проблему “сканеры подключены везде по-разному”.

Вместо того чтобы каждый сервис заново изобретал SonarQube, Dependency-Check, CycloneDX и coverage wiring, проект получает один Gradle-слой:

./gradlew securityAnalyze

Разработчики получают более раннюю обратную связь.

CI/CD получает предсказуемые artifacts.

Security-команда получает более стабильные форматы отчетов.

Multi-module проекты получают поведение, которое понимает структуру репозитория.

И главное: security tooling становится частью нормального Java engineering workflow, а не набором скриптов вокруг него.

Ссылки

Черновые хабы для Habr

Java, Gradle, DevOps, Информационная безопасность, CI/CD