Практический разбор того, как я вынес 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, а не набором скриптов вокруг него.
Ссылки
Secure Build Gradle Plugin: https://github.com/Niki-1337/secure-build-gradle-plugin
Secure Build Maven Extension: https://github.com/Niki-1337/secure-build-maven-extension
Черновые хабы для Habr
Java, Gradle, DevOps, Информационная безопасность, CI/CD
