Skip to content

Gradle plugin

The RingZero Gradle plugin resolves your build’s real dependency graph — direct and transitive, after Gradle’s version conflict resolution — and uploads a dependency snapshot to RingZero for advisory matching. Scan a whole build or individual modules, attach team labels, and review introducing dependencies and affected consumers. Build gating (failing check when the scan reports findings above a severity threshold) ships with the upcoming ringzeroCheck task — see Policy gates.

ComponentMinimum
Gradle7.6 (configuration cache supported from 8.1)
JVM running the build11
Plugin version0.1.0 (current)

The plugin runs on the build JVM only — your project’s toolchains and targets are unaffected.

Apply the plugin in the project you want scanned:

build.gradle.kts
plugins {
id("tech.ringzero.sca") version "0.1.0"
}

Apply the plugin to the root project for one aggregated snapshot, to individual modules for independent scans, or to both. Each project where the plugin is applied gets its own ringzero { } extension and ringzeroScan task.

Declare the version once in settings:

settings.gradle.kts
pluginManagement {
plugins {
id("tech.ringzero.sca") version "0.1.0"
}
}
// root build.gradle.kts
plugins {
id("tech.ringzero.sca")
}
ringzero {
project.set("acme-web")
}
// core/build.gradle.kts
plugins {
`java-library`
id("tech.ringzero.sca")
}
ringzero {
project.set("acme-core")
scan {
configurations.set(listOf("compileClasspath", "runtimeClasspath"))
excludedModules.set(listOf("com.acme.internal:*"))
}
}

Use task paths to choose the scan:

Terminal window
./gradlew :ringzeroScan # One aggregate snapshot and upload
./gradlew :core:ringzeroScan # Only core's snapshot and upload
./gradlew :core:ringzeroScan :api:ringzeroScan --parallel

The last command assumes the plugin is also applied to :api. An unqualified ringzeroScan selects matching tasks across projects using Gradle’s normal task selection, so use :ringzeroScan when you want only the aggregate upload. The aggregate task does not execute the module scan tasks.

scan.includeSubprojects defaults to true on the root and false on subprojects. Set it to false on the root for a root-local scan, or true on a module to aggregate that module and its descendants. The root plugin is optional when only module scans are needed.

An aggregate scan uses each module’s own configuration selection, excludedModules, includeTransitive, and manifest overrides when the module applies the plugin. Modules without the plugin use the aggregate owner’s scan settings. Local settings have their own defaults; they do not inherit the root’s DSL values. An exclusion in one module does not remove a shared dependency that another scanned module includes. Nodes and their dependency edges are deduplicated across the selected graphs.

scan.excludeProjects on the aggregate owner removes the named project roots and manifests. Dependencies of an unscanned project may still appear through a scanned consumer’s resolved graph. Path-qualified configuration patterns such as ":benchmark:runtimeClasspath" remain supported within the scope of the extension that declares them.

Each task uses its own project id, credentials, upload settings, suppressions file, and report directory. An aggregate uses its owner’s suppressions file; module-local suppression files apply to their independent scans. Share credentials through RINGZERO_* environment variables, ringzero.* Gradle properties, or a convention plugin. Reports default to that project’s build/ringzero/; detected and explicit manifest paths are relative to the root project directory. A nonempty scan.manifests on the aggregate owner replaces the full manifest list.

Configure team labels and custom metadata in each module’s build script:

ringZero {
teamName.set("maps")
attributes.put("customValue", "customValue")
attributes.put("service", "map-tiles")
attributes.put("domain", "geospatial")
attributes.put("costCenter", providers.gradleProperty("mapsCostCenter").orElse("engineering"))
}

ringZero and ringzero configure the same extension. teamName is optional; attributes is a map of string keys and string values for custom fields. Use the map for custom values rather than adding arbitrary top-level properties. Keys must be nonblank. Blank team names are omitted, and nonblank team names have surrounding whitespace removed.

Metadata belongs to the module where it is configured. Parent, sibling, and consumer settings are not inherited. Modules with no metadata keep those fields absent. To share values, explicitly configure the modules through your build’s convention plugin or shared configuration.

Module metadata includes its path, dependencies, team, and custom attributes. For example:

{
"path": ":maps",
"dependencies": [":foundation"],
"teamName": "maps",
"attributes": {
"customValue": "customValue",
"domain": "geospatial",
"service": "map-tiles"
}
}

Aggregate scans retain each module’s own metadata. Module-local scans also retain metadata for referenced project modules in their resolved graph, even when those modules aren’t independent scan roots. Unrelated modules are omitted. The Markdown report includes a module metadata table and a team column beside dependency attributions. The team in a row labels that module; it does not identify which team should fix an inherited dependency.

These fields are included in snapshot uploads and in configuration-cache-safe task inputs. Changing provider-backed metadata updates the next scan even when dependencies haven’t changed. The scan page can filter findings by team or module, either by dependencies the selection introduces or by modules affected. Expand a finding’s module attribution to see its configurations and each introducing dependency. These labels help route fixes; they do not change organization membership or grant access.

Large builds use multipart uploads automatically. The app loads module metadata and finding evidence in pages; custom attributes appear in module details. See large Gradle monorepos for report bundles and upload timeout guidance.

Every scan writes a readable dependencies.md report alongside snapshot.json in its reportsDirectory (default: that project’s build/ringzero/). Small scans contain the full report and snapshot; large scans index a bundle of report fragments and upload parts. Keep the whole directory when archiving it. Within a report, rows group by dependency version and list the modules using it:

ModuleConfigurationRelationshipDependency path
:coreruntimeClasspathdirect:core → com.acme:library@1.0
:serviceruntimeClasspathtransitive:service → :core → com.acme:library@1.0
:appruntimeClasspathtransitive:app → :service → :core → com.acme:library@1.0

Rows follow the resolved project dependency graph, with dependencies before their consumers. The Gradle root project : has no special priority: if it depends on :core, :core appears first. Ties between modules ready to list use alphabetical order. Cycles are grouped together, with alphabetical order inside the group.

Attribution is specific to a module and configuration. direct means the external dependency is an immediate, non-constraint dependency of that configuration. transitive means the path goes through another package or project. If both routes exist, the direct route wins. Each row includes one shortest resolved path, with alphabetical tie-breaking; dependency constraints influence version selection but do not create attribution paths.

Each configuration’s selected variants and versions are traversed before merging. A consumer can therefore attribute a different version than the library module it depends on. A runtime consumer can also include a dependency that the library’s own compile-only scan omits. Exclusions and includeTransitive apply independently to each scanned module’s contribution. With includeTransitive = false, inherited project dependencies do not get attributed to the consumer.

Small full snapshots include the same data in nodes[].attributions, plus a dependency-first modules list recording resolved project edges:

{
"module": ":service",
"context": "runtimeClasspath",
"relationship": "transitive",
"path": [":service", ":core", "com.acme:library@1.0"]
}

The plugin emits snapshot schema version 2, including each root’s variant-preserving resolution graph. The backend derives all introducing dependencies from these graphs, preserving declarations that the Markdown report’s shortest path does not show. The existing node-level relationship remains the aggregate graph classification; use the attribution’s relationship for a particular module. Only scanned modules get attribution rows, while project hops through unscanned modules can still appear in paths and the module graph. Reports are generated in report-only and offline modes as well as during uploads, and remain compatible with configuration cache reuse.

If you can’t use the plugins block (legacy builds), the artifact coordinates are tech.ringzero:ringzero-gradle-plugin:0.1.0 on the Gradle Plugin Portal and Maven Central:

buildscript {
repositories { gradlePluginPortal() }
dependencies { classpath 'tech.ringzero:ringzero-gradle-plugin:0.1.0' }
}
apply plugin: 'tech.ringzero.sca'

Uploads authenticate with a scoped API key (rz_key_…), issued once by an org admin in Settings → API keys. Create a dedicated key for this build with the minimum it needs:

  • Scopes: sca:upload + scans:read. That covers ringzeroScan uploads today and, once ringzeroCheck ships, polling severity counts. Add findings:read only if you want the per-CVE table in build output — without it, a blocked build still fails correctly and prints counts plus a link to the scan in the app. CVE detail never appears in ringzeroScan output — the snapshot upload carries no vulnerability data; advisory matching happens server-side.
  • Projects: restricted to just this project.

See Authentication & permissions for the full scope and role model.

The plugin looks for a key in this order:

  1. The apiKey property in the ringzero { } DSL (highest precedence)
  2. The RINGZERO_API_KEY environment variable
  3. The ringzero.apiKey Gradle property (e.g. ~/.gradle/gradle.properties)

Local development — put the key in your user-level Gradle properties file, which lives outside any repository:

# ~/.gradle/gradle.properties (never committed)
ringzero.apiKey=rz_key_5f2a…

CI — inject the key from your secret store as RINGZERO_API_KEY (see the GitHub Actions example below). The plugin never logs the key and masks it in --info/--debug output.

Explicit wiring — if you need to wire the key yourself (custom resolution order, a different env var, a file-based secret), use the Provider API so the value stays lazy and configuration-cache-friendly:

ringzero {
apiKey = providers.environmentVariable("RINGZERO_API_KEY")
.orElse(providers.gradleProperty("ringzero.apiKey"))
}

Tell RingZero which project the snapshot belongs to — the project id is on the project’s settings page in the app. There is nothing to configure for the organization: the API key is org-scoped, so the org is derived from the key itself.

ringzero {
project.set("prj_6h2m") // or the project slug, e.g. "acme-web"
}

If project is omitted and no environment or Gradle property supplies it, the plugin uses the name of the project where it is applied — fine for a first run, but pin it explicitly so renames don’t fork your scan history. A new slug creates the project on your first upload. If your API key carries a project restriction, a slug outside that list fails the upload with 422 unknown_project (the plugin prints the slug it tried and where to find the real id) — restricted keys can never create projects.

TaskWhat it does
ringzeroScanResolves the selected configurations (default: runtimeClasspath) in the task’s project scope, builds the dependency snapshot, and uploads it. Root tasks aggregate by default; module tasks scan their own project. Prints the scan id and a link to the scan in the app.
ringzeroCheck Coming soonWill depend on ringzeroScan; polls the scan until it completes, evaluates your failOn policy, and fails the build on violation.

Run a one-off scan:

Terminal window
./gradlew :ringzeroScan
> Task :ringzeroScan
RingZero: snapshot of 143 dependencies (12 direct, 131 transitive) from 4 projects
RingZero: uploaded — scan scn_7g8h queued
RingZero: https://app.ringzero.tech/scans/scn_7g8h

ringzeroScan never blocks on scan results: the upload carries a pure dependency graph (no vulnerability data), advisory matching runs server-side, and the findings appear in the app. Your build finishes as soon as the upload is accepted.

With no API key configured, ringzeroScan still resolves the graph and writes snapshot.json and dependencies.md — it skips the upload and succeeds:

> Task :ringzeroScan
RingZero: snapshot of 143 dependencies (12 direct, 131 transitive) from 4 projects
RingZero: no API key configured — snapshot written to build/ringzero/snapshot.json, upload skipped. Set RINGZERO_API_KEY (or ringzero.apiKey) to upload.

This is the zero-friction first run: inspect exactly what would be uploaded before creating a key. The Markdown report includes module metadata and dependency paths; advisory matching requires an uploaded scan. The same behavior applies under gradle --offline, and scan.json records why nothing was uploaded ({ "uploaded": false, "reason": "no-api-key" | "offline" }).

Fail the build on policy violations (coming soon)

Section titled “Fail the build on policy violations (coming soon)”

Once shipped, you declare a threshold (failOn { severity = "high" }), wire ringzeroCheck onto check, and a blocked build reports exactly what tripped the gate:

> Task :ringzeroCheck FAILED
RingZero: scan scn_7g8h completed — 1 high, 3 medium, 9 low
HIGH CVE-2025-4410 com.alibaba:fastjson 1.2.68 → fix: 1.2.83
via: build.gradle > acme-web-core > fastjson
fastjson deserialization of untrusted input can lead to RCE
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':ringzeroCheck'.
> 1 finding at or above severity 'high' (policy: failOn.severity=high)
Details: https://app.ringzero.tech/scans/scn_7g8h

The per-CVE lines require findings:read; with only scans:read the failure shows the counts and the scan link (see the key recipes for the trade-off). Either way, CVE detail comes from RingZero’s API after the server finishes advisory matching — never from the build itself.

To silence a triaged finding without losing sight of it — with a required reason and an optional expiry — use a committed suppressions file (ringzero-suppressions.yml). ringzeroScan uploads it with every snapshot today: suppressed findings drop out of the severity counts and the (future) gate but stay visible in the app and API.

The full policy surface (CVSS thresholds, fixableOnly/reachableOnly filters, grace periods for freshly published advisories, behavior when the API is unreachable) is in the configuration reference.

GitHub Actions example — key from the repository’s encrypted secrets, a fresh snapshot uploaded on every PR:

name: Build
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v4
- name: Build and scan
env:
RINGZERO_API_KEY: ${{ secrets.RINGZERO_API_KEY }}
run: ./gradlew build :ringzeroScan

The explicit :ringzeroScan path selects only the root aggregate, even when subprojects also apply the plugin. For a module-only CI job, select its task path, such as :core:ringzeroScan. On Gradle 8.1+, you can add --configuration-cache when the other plugins in your build support it.

When ringzeroCheck ships, swapping :ringzeroScan for :ringzeroCheck in this step turns the upload into a policy gate.

The same pattern applies to any CI system: store the key in the platform’s secret store and expose it to the build as RINGZERO_API_KEY. Never echo it, and never pass it as a -P command-line property (command lines end up in process listings and CI logs).

Concurrent jobs are safe: uploads are idempotent on the snapshot’s content hash, so two jobs building the same commit converge on one scan instead of creating duplicates.

build.gradle.kts
plugins {
`java-library`
id("tech.ringzero.sca") version "0.1.0"
}
ringzero {
project.set("acme-web")
teamName.set("platform")
attributes.put("service", "acme-web")
scan {
configurations.set(listOf("runtimeClasspath"))
includeTransitive.set(true)
}
}

Run with --info to see what the plugin actually did:

  • the projects and configurations selected (after excludedConfigurations / excludeProjects),
  • counts of excluded modules and of loaded suppressions (expired entries warn even without --info; matched counts are server-side — see the scan in the app),
  • the snapshot file path and byte size,
  • the API base URL in use and which precedence source supplied it (DSL, env, or gradle.properties),
  • request ids (X-Request-Id) for every API call — quote these to support,
  • retry/backoff events.

The API key is never logged and is masked in --info/--debug output.

Common failures:

SymptomCauseFix
401 invalid_token on uploadKey missing/revoked/expired, or the env var isn’t reaching the Gradle daemonCheck the resolution order; restart the daemon after changing env; re-issue in Settings → API keys
403 insufficient_scopeKey lacks a scope the task needsringzeroScan needs sca:upload — see recipes
422 unknown_projectThe configured slug/id isn’t covered by the key’s project restrictionPin project to a slug/id in the key’s list, or issue a key without a restriction
Snapshot written but nothing uploadedNo API key resolved — the task ran in report-only modeProvide the key via RINGZERO_API_KEY or ringzero.apiKey; the log line names the mode
Build passes locally, fails in CIDifferent key scopes, or CI resolves different dependency versionsCompare the --info selection output; pin versions or use dependency locking