Практический разбор Maven core extension, который встраивает Java security checks в Maven lifecycle, а не заставляет копировать scanner-конфигурацию по pipeline-файлам.

Вступление

Проблема никогда не была в том, что Maven-проекты не умеют запускать security-инструменты.

Умеют.

Можно в pipeline вызвать tests, Dependency-Check, CycloneDX и SonarQube. Можно прописать plugin blocks в pom.xml. Можно скопировать рабочую конфигурацию из одного сервиса в другой и назвать это стандартом.

Какое-то время это даже работает.

Потом начинаются маленькие отличия.

В одном сервисе есть JaCoCo, но XML coverage не передается в SonarQube. В другом Dependency-Check делает только HTML. В multi-module проекте SBOM генерируется от root aggregator, который сам не является runtime-приложением. В третьем pipeline забыли merge request metadata, поэтому SonarQube analysis технически прошел, но практически получился неполным.

Это и есть security build drift.

Выглядит как автоматизация. Работает как несогласованность.

Я сделал secure-maven-extension, чтобы закрыть именно эту проблему для Maven-проектов.

Не заменять сканеры.

А заставить Maven lifecycle нести security workflow.

Проект: Secure Build Maven Extension

Как обычно начинается Maven DevSecOps

Типичный pipeline выглядит примерно так:

script:
  - ./mvnw test
  - ./mvnw org.owasp:dependency-check-maven:check
  - ./mvnw org.cyclonedx:cyclonedx-maven-plugin:makeBom
  - ./mvnw sonar:sonar

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

На масштабе это превращается в maintenance pattern, которым никто до конца не владеет.

Часть настроек живет в CI/CD. Часть в pom.xml. Часть в документации. Часть в переменных окружения, о которых локальный разработчик узнает только после падения pipeline.

Новый сервис каждый раз заново отвечает на одни и те же вопросы:

  • как включить coverage;

  • куда класть Dependency-Check reports;

  • какие форматы нужны security-команде;

  • как передать SonarQube token;

  • как отличить branch analysis от MR analysis;

  • как сделать SBOM для multi-module проекта;

  • как повторить это локально.

И в какой-то момент становится понятно: build сам по себе не security-aware. Pipeline просто вызывает сканеры рядом с build.

Почему обычный подход неудобен

CI/CD должен быть общей средой выполнения.

Он должен запускать чистый build, публиковать artifacts, включать gates, хранить logs и давать auditability.

Но когда CI/CD еще и владеет всей scanner-конфигурацией, каждый репозиторий становится отдельной custom-интеграцией.

Для разработчика это выглядит так:

mvn verify

локально проходит, но pipeline делает что-то другое:

  • другие goals;

  • другие properties;

  • другие report paths;

  • другой набор форматов;

  • другая SonarQube metadata.

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

Эту дыру я и хотел закрыть.

Maven команды должны остаться знакомыми, но lifecycle должен запускать один и тот же AppSec behavior локально и в CI/CD.

Принцип решения

Ключевая идея:

оставить Maven experience нативным,
но внедрить повторяемые security conventions в lifecycle

Разработчику не нужен отдельный security script для каждого сервиса.

CI/CD не должен заново описывать scanner conventions.

Security-команда не должна по каждому репозиторию объяснять, где лежат отчеты и какие properties нужно передать.

Build должен знать скучные детали сам.

Именно поэтому это Maven core extension, а не просто еще одна команда в pipeline.

Почему Maven core extension

Обычный Maven plugin все равно обычно нужно явно конфигурировать в каждом проекте.

Это тоже можно стандартизировать, но copy-paste полностью не исчезает.

Core extension дает более раннюю точку интеграции.

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

.mvn/extensions.xml

Пример:

<extensions>
  <extension>
    <groupId>io.github.niki1337.securebuild</groupId>
    <artifactId>secure-maven-extension</artifactId>
    <version>0.1.0</version>
  </extension>
</extensions>

Внутри extension работает на стадии Maven afterProjectsRead.

Это важный момент.

На этой стадии Maven уже прочитал root pom.xml и module POMs. Уже известны packaging, modules, existing plugins и properties. Но lifecycle еще не стартовал.

То есть extension может посмотреть на проект и внедрить нужные security plugins до фаз initialize, package, verify и sonar:sonar.

Это удобное место для conventions.

Что подключается под капотом

Extension работает с инструментами, которые Java-команды и так знают:

  • jacoco-maven-plugin для coverage;

  • sonar-maven-plugin для SonarQube analysis;

  • dependency-check-maven для dependency risk reports;

  • cyclonedx-maven-plugin для SBOM.

Разработчик продолжает использовать Maven:

mvn package
mvn verify
mvn sonar:sonar

Разница в том, что команды становятся security-aware.

Например:

mvn package

может собрать приложение и сгенерировать CycloneDX SBOM.

mvn verify

может запустить tests, JaCoCo coverage и Dependency-Check.

mvn verify sonar:sonar

может отправить SonarQube analysis с branch/MR metadata, binaries и coverage paths.

И это главное: workflow выглядит как Maven, а не как набор сканеров, приклеенных вокруг Maven.

Конфигурация из разных источников

Реальная инфраструктура редко бывает идеальной.

Локально разработчик может передавать -D.... В CI/CD значения приходят через environment variables. Какие-то стабильные настройки удобно хранить в pom.xml.

Extension поддерживает все эти источники:

  • environment variables;

  • Maven user properties;

  • project properties из pom.xml;

  • system properties.

Пример project defaults:

<properties>
  <secure.serviceName>payment-api</secure.serviceName>
  <sonar.projectKey>payment-api</sonar.projectKey>
  <sonar.projectName>Payment API</sonar.projectName>
</properties>

Пример CI variables:

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

Пример локального override:

mvn verify \
  -Dsecure.serviceName=payment-api \
  -Dsonar.projectKey=payment-api

Смысл не в том, чтобы заставить всех использовать один стиль конфигурации.

Смысл в том, чтобы итоговое поведение было одинаковым.

Coverage без повторяемой проводки

Coverage часто ломает AppSec workflow тихо.

SonarQube может запуститься без coverage. JaCoCo может сгенерировать отчет, но если XML output не включен или path не передан в SonarQube, анализ будет слабее.

Extension inject-ит JaCoCo для Java jar и war проектов, если JaCoCo еще не настроен.

Lifecycle wiring:

initialize -> jacoco:prepare-agent
verify     -> jacoco:report

XML отчет:

target/site/jacoco/jacoco.xml

Этот path автоматически передается в:

sonar.coverage.jacoco.xmlReportPaths

Это не самая эффектная часть проекта.

Но именно такие повторяемые детали и создают drift, когда их копируют руками.

SonarQube: токена мало

Частая ошибка: считать, что SonarQube setup это URL, project key и token.

Для Java-проектов нормальный analysis зависит еще от source paths, test paths, compiled binaries, coverage XML, branch metadata и merge request metadata.

Extension готовит properties:

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

В GitLab merge request pipeline он берет:

CI_PIPELINE_SOURCE=merge_request_event
CI_MERGE_REQUEST_IID
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
CI_MERGE_REQUEST_TARGET_BRANCH_NAME

И маппит в:

sonar.pullrequest.key
sonar.pullrequest.branch
sonar.pullrequest.base

Для branch pipeline задается:

sonar.branch.name

Это ровно тот тип логики, который становится хрупким, если он размазан по десяткам .gitlab-ci.yml.

В core extension это версионируется и переиспользуется.

Dependency-Check в одном формате

OWASP Dependency-Check inject-ится в Maven lifecycle.

Для single-module проекта:

verify -> dependency-check:check

Для multi-module:

verify -> dependency-check:aggregate

Форматы отчетов:

HTML
JSON
SARIF
XML

Путь:

target/reports/dependency-check

По умолчанию отключаются network-dependent analyzers:

  • RetireJS;

  • Node audit;

  • Node package analyzer;

  • OSS Index;

  • hosted suppressions.

В закрытых средах это важно.

Если каждый pipeline зависит от внешнего endpoint, то безопасность внезапно начинает зависеть от доступности интернета, proxy и rate limits. Внутренний mirror решает эту проблему лучше.

Пример:

DT_API_URL=https://dependency-track.example.com mvn verify

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

failBuildOnCVSS = 11

Это не потому что vulnerabilities не важны.

Это потому что первая стадия внедрения часто должна дать видимость и данные. Blocking gates лучше включать после triage и настройки noise reduction.

SBOM должен описывать полезный artifact

SBOM не должен быть просто файлом ради файла.

Он должен описывать то, что реально деплоится.

Для single-module проектов extension запускает:

package -> cyclonedx:makeBom

Отчеты:

target/reports/cyclonedx

Включаются:

compile dependencies
runtime dependencies

Исключаются:

test scope
provided scope
system scope

Для multi-module проектов root часто является только aggregator. Генерировать SBOM только от root бывает бесполезно.

Extension пытается найти Spring Boot module по:

org.springframework.boot:spring-boot-maven-plugin

Если находит, inject-ит CycloneDX туда.

Если нет, fallback на aggregate SBOM на root:

package -> cyclonedx:makeAggregateBom

Это практичнее для реальных Maven-репозиториев, где deployable artifact живет не в root.

Multi-module Maven

Multi-module Maven проекты требуют отдельной логики.

Extension считает build multi-module, когда Maven видит больше одного project и secure.forceSimpleMode не включен.

Java modules:

jar
war

Можно фильтровать:

<properties>
  <secure.includedModules>api,service</secure.includedModules>
  <secure.excludedModules>test-fixtures</secure.excludedModules>
</properties>

В multi-module режиме extension:

  • настраивает SonarQube на root project;

  • inject-ит JaCoCo в Java-модули;

  • добавляет module-level SonarQube paths;

  • inject-ит aggregate Dependency-Check на root;

  • генерирует CycloneDX из Spring Boot module, если возможно;

  • fallback-ится на aggregate SBOM, если deployable module не найден.

Это отличие между “мы вызвали scanner command” и “build понимает структуру Maven-проекта”.

CI/CD становится меньше

После этого pipeline может быть проще.

GitLab CI пример:

security:maven:
  image: eclipse-temurin:17
  stage: test
  script:
    - ./mvnw -B verify
  artifacts:
    when: always
    expire_in: 7 days
    paths:
      - target/reports/dependency-check/
      - target/reports/cyclonedx/
      - "**/target/reports/dependency-check/"
      - "**/target/reports/cyclonedx/"
      - "**/target/site/jacoco/"

SonarQube можно запускать отдельно:

sonarqube:maven:
  image: eclipse-temurin:17
  stage: test
  script:
    - ./mvnw -B verify sonar:sonar
  rules:
    - if: '$SONAR_TOKEN'

Pipeline остается читаемым.

Security wiring живет в Maven extension.

Чем Maven extension отличается от Gradle plugin

Обе идеи решают одну проблему: security build drift.

Но build systems разные.

Gradle task-oriented, поэтому Gradle plugin дает tasks:

securityAnalyze
dependencyCheckAnalyze
dependencyCheckAggregate
cyclonedxDirectBom
sonar
sonarHelp

Maven lifecycle-oriented, поэтому Maven extension inject-ит security tooling в phases:

initialize
package
verify
sonar:sonar

Коротко:

Gradle plugin:
  security checks как Gradle tasks и conventions

Maven extension:
  обычные Maven lifecycle commands становятся security-aware

Реализация разная.

Цель одна: меньше drift, больше repeatability.

Что extension не решает

Это один build-time слой.

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

  • централизованный vulnerability management;

  • ручной triage;

  • DefectDojo или Dependency-Track;

  • secret scanning;

  • DAST;

  • container scanning;

  • IaC scanning;

  • release approval policy.

Он отвечает за Maven Java build-time checks:

coverage
SonarQube metadata
SCA reports
SBOM generation
repeatable lifecycle behavior

Secret scanning, например, лучше ставить раньше: до commit и push. Это другой слой Secure SDLC.

Итог

secure-maven-extension превращает разрозненную scanner-конфигурацию в переиспользуемую Maven lifecycle convention.

Вместо того чтобы каждый проект вручную подключал JaCoCo, SonarQube, Dependency-Check и CycloneDX, extension inject-ит их до старта lifecycle.

Разработчики продолжают использовать обычные команды:

mvn package
mvn verify
mvn sonar:sonar

Но build становится security-aware.

И это главное.

Не сделать еще один scanner.

А сделать существующие AppSec tools проще для одинакового внедрения в Maven-проекты.

Ссылки

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

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

Комментарии (0)