Хватит копировать security YAML: AppSec-слой для Java-проектов через Gradle convention plugin

Страницы:  1

Ответить
 

Professor Seleznov


Практический разбор того, как я вынес 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-Источник
 
Loading...
Error