DEV Community

Nikolay Kuziev
Nikolay Kuziev

Posted on

Stop Copy-Pasting Security YAML: A Gradle Build Layer for Java AppSec

The hard part of Java AppSec is usually not finding another scanner.

Most teams already have the scanners.

They have SonarQube for code analysis. They have OWASP Dependency-Check for dependency risk. They have CycloneDX for SBOM generation. They have JaCoCo or Kover for coverage. They have GitLab CI, GitHub Actions, Jenkins, or something similar to run all of it.

And still, the workflow drifts.

One repository writes Dependency-Check reports to one path. Another produces only HTML. One pipeline sends merge request metadata to SonarQube correctly. Another accidentally runs branch analysis for everything. One service generates an SBOM from runtime dependencies. Another includes test dependencies and makes the report noisy. A multi-module project needs a special exception, so someone copies another YAML block and edits it until the pipeline is green.

None of this looks dramatic on day one.

After a few months, it becomes security build drift.

That is the problem I wanted to solve with secure-build-gradle-plugin.

Not by inventing a new scanner.

By moving security wiring into a reusable Gradle build layer.

The problem I ran into

The usual DevSecOps starting point is CI/CD YAML.

For a single Java service, this is fine:

script:
  - ./gradlew test
  - ./gradlew dependencyCheckAnalyze
  - ./gradlew cyclonedxBom
  - ./gradlew sonar
Enter fullscreen mode Exit fullscreen mode

It is direct. It is visible. It works.

The problem starts when the same idea spreads across many repositories.

Every service becomes responsible for remembering how security tools should be configured. Every team copies a slightly different version. Every pipeline slowly becomes a custom integration.

The failure mode is not only technical. It affects trust.

Developers do not know which command reproduces the pipeline locally. Security teams do not know whether two reports were generated with the same assumptions. CI/CD becomes full of scanner plumbing instead of clear build steps.

At that point, the issue is not "we need more tools".

The issue is "we need one place for the conventions".

Why CI/CD-only security wiring is uncomfortable

CI/CD is good at clean execution. It gives a fresh environment, shared logs, artifacts, gates, and a common place for enforcement.

But CI/CD is not a great place to own all security behavior.

When the logic lives only in pipeline files, local development becomes second-class. Developers push to find out what the security workflow thinks. If a report path is wrong, the pipeline fails. If SonarQube metadata is incomplete, the analysis is misleading. If Dependency-Check output differs between services, the security team has to normalize the mess later.

This creates the worst kind of friction: nobody is arguing about risk yet. Everyone is arguing about tool wiring.

I wanted CI/CD to execute the security workflow, not define it from scratch in every repository.

That distinction matters.

The design principle

The rule I used for the Gradle plugin was simple:

security behavior should live close to the code,
and CI/CD should run the same behavior without reimplementing it.
Enter fullscreen mode Exit fullscreen mode

That means developers should be able to run the same checks before opening a merge request:

./gradlew clean securityAnalyze --no-daemon
Enter fullscreen mode Exit fullscreen mode

And CI/CD should be able to run the same task and collect predictable reports.

The result should not depend on whether the command was executed on a laptop or inside a pipeline, except for environment-specific details like tokens and branch metadata.

That is the point of the build tooling layer.

The build tooling layer

secure-build-gradle-plugin is a Gradle convention plugin.

A project applies the plugin once:

plugins {
  id "java"
  id "io.github.niki1337.securebuild.gradle-java" version "0.1.0"
}
Enter fullscreen mode Exit fullscreen mode

Then the project keeps only project-specific values in one place:

securityConventions {
  serviceName = "payment-api"
  sonarProjectKey = "payment-api"
  allowLocalSonar = false
}
Enter fullscreen mode Exit fullscreen mode

For a multi-module project, the root build can describe which modules matter:

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"]
}
Enter fullscreen mode Exit fullscreen mode

That changes the ownership model.

CI/CD still runs the checks. Security still owns policy. Developers still fix findings.

But the repeated scanner wiring lives in the build system, versioned as engineering code instead of copy-pasted as pipeline folklore.

What runs under the hood

The plugin connects existing tools:

  • SonarQube analysis;
  • OWASP Dependency-Check;
  • CycloneDX SBOM generation;
  • JaCoCo or Kover coverage;
  • Gradle single-module and multi-module behavior;
  • Git branch and GitLab merge request metadata.

The plugin is not trying to replace those tools. That would be the wrong abstraction.

The value is in making them behave consistently.

A developer gets one practical entry point:

./gradlew clean securityAnalyze --no-daemon
Enter fullscreen mode Exit fullscreen mode

That task can run tests, produce coverage, generate SBOM output, and run dependency analysis. When a developer needs to inspect individual pieces, the underlying tasks are still there:

./gradlew cyclonedxDirectBom --no-daemon
./gradlew dependencyCheckAnalyze --no-daemon
./gradlew sonarHelp --no-daemon
Enter fullscreen mode Exit fullscreen mode

For multi-module builds, aggregate analysis stays explicit:

./gradlew dependencyCheckAggregate --no-daemon
Enter fullscreen mode Exit fullscreen mode

The point is not to hide the tools. The point is to make the normal path obvious.

Moving feedback before the merge request

One of the biggest wins is psychological.

If AppSec checks run only after a push, developers treat findings as pipeline problems. If they can run the same workflow locally, findings become engineering feedback.

That is a healthier model.

A developer can run:

./gradlew securityAnalyze --no-daemon
Enter fullscreen mode Exit fullscreen mode

before opening a merge request. They can see dependency findings, SBOM output, coverage reports, and local scanner artifacts before the branch becomes a shared review object.

CI/CD still matters. I do not trust laptops as the final gate. But local execution reduces surprise, and reducing surprise is one of the best ways to make security tooling accepted.

SonarQube metadata belongs in conventions

SonarQube is easy to configure almost correctly.

That is the dangerous part.

The scanner may run, the job may pass, and the analysis may still be incomplete because Java binaries, libraries, coverage XML, branch names, or merge request metadata were not passed correctly.

That is why I do not want every repository hand-writing this logic.

The plugin resolves SonarQube configuration from environment variables, Gradle properties, or the securityConventions block:

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

Then it prepares the Java analysis properties that teams usually forget at least once:

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
Enter fullscreen mode Exit fullscreen mode

In GitLab merge request pipelines, it can map CI variables to SonarQube pull request properties:

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
Enter fullscreen mode Exit fullscreen mode

For branch pipelines, it sets branch analysis metadata instead.

This is exactly the kind of boring detail that should be solved once and reused.

Dependency-Check reports should be boring

Dependency-Check is useful when reports are predictable.

I do not want one service producing JSON, another producing only HTML, and another hiding reports in a custom directory. That creates unnecessary work downstream.

The plugin standardizes formats:

HTML
JSON
SARIF
XML
Enter fullscreen mode Exit fullscreen mode

and writes reports to:

build/reports/dependency-check
Enter fullscreen mode Exit fullscreen mode

By default, it avoids depending on network-heavy analyzers that can make CI/CD slow or unstable in restricted environments, such as OSS Index, RetireJS, Node audit, Node package analysis, hosted suppressions, and CISA KEV analyzer.

If an internal mirror exists, the build can use it through configuration like:

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

The default behavior is visibility first, not instant blocking. A build can generate useful reports without failing by CVSS score immediately. Once the team understands the noise and triage process, stricter gates can be introduced deliberately.

That is a practical AppSec adoption path.

SBOM output should describe the runtime artifact

SBOM generation is not valuable just because a file exists.

The SBOM has to describe something useful.

If one service includes test dependencies and another does not, comparison becomes messy. If a multi-module root project is only an aggregator, a root-level SBOM may say very little about the deployable application.

The plugin configures CycloneDX with runtime-oriented output in mind:

runtime dependencies included
test dependencies skipped
license text not embedded
BOM serial number disabled
metadata noise reduced
Enter fullscreen mode Exit fullscreen mode

Typical output paths stay predictable:

build/reports/cyclonedx
build/reports/cyclonedx-direct
Enter fullscreen mode Exit fullscreen mode

For Spring Boot style multi-module projects, the useful SBOM often comes from the deployable module, not the root. The plugin tries to make that the default path while still copying reports back to predictable root locations for CI/CD artifact collection.

This is the kind of detail that looks small until you have ten services and every one produces a different SBOM shape.

Coverage wiring should not be tribal knowledge

Coverage is another source of drift.

Some projects use JaCoCo. Some already use Kover. Some generate XML. Some do not. Some pass the report to SonarQube. Some forget.

The plugin supports a simple default:

securityConventions {
  coverageProvider = "auto"
}
Enter fullscreen mode Exit fullscreen mode

In auto mode, it uses Kover when Kover is already present. Otherwise, it applies and configures JaCoCo.

For JaCoCo, it enables XML output and wires the standard path into SonarQube:

build/reports/jacoco/test/jacocoTestReport.xml
Enter fullscreen mode Exit fullscreen mode

Again, not glamorous. Just useful.

A good build convention removes repeated decisions that are easy to get wrong.

Multi-module Gradle builds are where this pays off

Single-module scanner integration is easy to demo.

Multi-module builds are where the real problems appear.

A root project may not contain production code. Some modules are deployable. Some are libraries. Some are test fixtures. Some should be excluded from coverage or dependency analysis. SonarQube needs paths per module. Dependency-Check may need aggregate behavior. SBOM generation should describe the deployable artifact.

The plugin detects Java subprojects using java and java-library, supports module filters, configures coverage per Java module, collects XML paths, prepares root-level SonarQube analysis, and exposes a root-level security workflow.

A root command can still drive the work:

./gradlew clean securityAnalyze --no-daemon
Enter fullscreen mode Exit fullscreen mode

That is the difference between a scanner integration and a build tooling layer.

CI/CD becomes smaller

Once the build owns the security wiring, CI/CD becomes easier to read.

A GitLab job can be this boring:

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/"
Enter fullscreen mode Exit fullscreen mode

SonarQube can stay separate and token-driven:

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

The important part is architectural:

CI/CD calls build tasks.
CI/CD does not reimplement the build conventions.
Enter fullscreen mode Exit fullscreen mode

Where pre-commit and Gitleaks fit

I do not see the Gradle plugin as the first security layer.

For secrets, I want something earlier.

A pre-commit hook with Gitleaks can catch obvious secrets before a commit exists. That is the right place for that class of finding.

The Gradle plugin covers the next layer: build-time AppSec checks that are heavier than a commit hook but still useful before code is pushed or reviewed.

The layered model looks like this:

before commit
  Gitleaks + pre-commit

before review
  ./gradlew securityAnalyze

after push
  CI/CD runs the same build tasks and publishes artifacts
Enter fullscreen mode Exit fullscreen mode

This keeps pre-commit fast, keeps Gradle responsible for build-aware checks, and keeps CI/CD responsible for enforcement.

What developers get

Developers get fewer mystery pipelines.

They can run one command locally and get the same shape of output that CI/CD will collect later. They do not need to remember where Dependency-Check writes reports or which SBOM task matters for a multi-module Spring Boot project.

They also get a workflow that respects their time. Fast checks happen in hooks. Heavier checks happen through the build. CI/CD verifies the result.

That is a much better experience than discovering every AppSec issue after a push.

What the security team gets

The security team gets consistency.

Reports arrive in predictable formats and paths. SonarQube analysis receives the metadata it needs. SBOM generation follows the same assumptions across services. Multi-module projects behave less like special snowflakes. CI/CD jobs become easier to audit because the security wiring is versioned in one build layer.

Most importantly, the team gets a better adoption story.

Instead of telling every service owner to copy another YAML block, AppSec can offer a build convention:

apply the plugin
set the project values
run securityAnalyze locally and in CI/CD
Enter fullscreen mode Exit fullscreen mode

That is easier to scale.

The result

secure-build-gradle-plugin is not another scanner.

It is a way to make existing scanners usable in normal Java engineering.

The goal is not to move all security into Gradle. The goal is to put build-aware security checks in the build system, where developers can run them locally and CI/CD can reproduce them without copy-paste.

That is the pattern I want across Secure SDLC:

local where possible
build-aware where useful
CI/CD for enforcement
Enter fullscreen mode Exit fullscreen mode

When that pattern works, security stops being a pile of pipeline scripts and becomes part of the engineering workflow.

Project links:

Top comments (0)