|
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 поддерживает:
В режиме 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-Источник
|