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.
Requirements
Section titled “Requirements”| Component | Minimum |
|---|---|
| Gradle | 7.6 (configuration cache supported from 8.1) |
| JVM running the build | 11 |
| Plugin version | 0.1.0 (current) |
The plugin runs on the build JVM only — your project’s toolchains and targets are unaffected.
Installation
Section titled “Installation”Apply the plugin in the project you want scanned:
plugins { id("tech.ringzero.sca") version "0.1.0"}plugins { id 'tech.ringzero.sca' version '0.1.0'}Multi-project builds
Section titled “Multi-project builds”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:
pluginManagement { plugins { id("tech.ringzero.sca") version "0.1.0" }}
// root build.gradle.ktsplugins { id("tech.ringzero.sca")}ringzero { project.set("acme-web")}
// core/build.gradle.ktsplugins { `java-library` id("tech.ringzero.sca")}ringzero { project.set("acme-core") scan { configurations.set(listOf("compileClasspath", "runtimeClasspath")) excludedModules.set(listOf("com.acme.internal:*")) }}pluginManagement { plugins { id 'tech.ringzero.sca' version '0.1.0' }}
// root build.gradleplugins { id 'tech.ringzero.sca'}ringzero { project = 'acme-web'}
// core/build.gradleplugins { id 'java-library' id 'tech.ringzero.sca'}ringzero { project = 'acme-core' scan { configurations = ['compileClasspath', 'runtimeClasspath'] excludedModules = ['com.acme.internal:*'] }}Use task paths to choose the scan:
./gradlew :ringzeroScan # One aggregate snapshot and upload./gradlew :core:ringzeroScan # Only core's snapshot and upload./gradlew :core:ringzeroScan :api:ringzeroScan --parallelThe 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.
Module teams and custom attributes
Section titled “Module teams and custom attributes”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 { teamName = 'maps' attributes = [ customValue: 'customValue', service: 'map-tiles', domain: 'geospatial', costCenter: '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.
Module dependency attribution
Section titled “Module dependency attribution”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:
| Module | Configuration | Relationship | Dependency path |
|---|---|---|---|
:core | runtimeClasspath | direct | :core → com.acme:library@1.0 |
:service | runtimeClasspath | transitive | :service → :core → com.acme:library@1.0 |
:app | runtimeClasspath | transitive | :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'Provide your API key — securely
Section titled “Provide your API key — securely”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 coversringzeroScanuploads today and, onceringzeroCheckships, polling severity counts. Addfindings:readonly 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 inringzeroScanoutput — 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:
- The
apiKeyproperty in theringzero { }DSL (highest precedence) - The
RINGZERO_API_KEYenvironment variable - The
ringzero.apiKeyGradle 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"))}ringzero { apiKey = providers.environmentVariable('RINGZERO_API_KEY') .orElse(providers.gradleProperty('ringzero.apiKey'))}Associate the build with your project
Section titled “Associate the build with your project”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"}ringzero { project = '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.
| Task | What it does |
|---|---|
ringzeroScan | Resolves 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 soon | Will depend on ringzeroScan; polls the scan until it completes, evaluates your failOn policy, and fails the build on violation. |
Run a one-off scan:
./gradlew :ringzeroScan> Task :ringzeroScanRingZero: snapshot of 143 dependencies (12 direct, 131 transitive) from 4 projectsRingZero: uploaded — scan scn_7g8h queuedRingZero: https://app.ringzero.tech/scans/scn_7g8hringzeroScan 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.
Report-only mode (no API key)
Section titled “Report-only mode (no API key)”With no API key configured, ringzeroScan still resolves the graph and
writes snapshot.json and dependencies.md — it skips the upload and succeeds:
> Task :ringzeroScanRingZero: snapshot of 143 dependencies (12 direct, 131 transitive) from 4 projectsRingZero: 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 FAILEDRingZero: 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_7g8hThe 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.
Running in CI
Section titled “Running in CI”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 :ringzeroScanThe 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.
A complete example
Section titled “A complete example”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) }}plugins { id 'java-library' id 'tech.ringzero.sca' version '0.1.0'}
ringzero { project = 'acme-web' teamName = 'platform' attributes = [service: 'acme-web']
scan { configurations = ['runtimeClasspath'] includeTransitive = true }}Troubleshooting & diagnostics
Section titled “Troubleshooting & diagnostics”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:
| Symptom | Cause | Fix |
|---|---|---|
401 invalid_token on upload | Key missing/revoked/expired, or the env var isn’t reaching the Gradle daemon | Check the resolution order; restart the daemon after changing env; re-issue in Settings → API keys |
403 insufficient_scope | Key lacks a scope the task needs | ringzeroScan needs sca:upload — see recipes |
422 unknown_project | The configured slug/id isn’t covered by the key’s project restriction | Pin project to a slug/id in the key’s list, or issue a key without a restriction |
| Snapshot written but nothing uploaded | No API key resolved — the task ran in report-only mode | Provide the key via RINGZERO_API_KEY or ringzero.apiKey; the log line names the mode |
| Build passes locally, fails in CI | Different key scopes, or CI resolves different dependency versions | Compare the --info selection output; pin versions or use dependency locking |
Next steps
Section titled “Next steps”- Every DSL block and property — exclusions, suppressions, policy gates, offline behavior: Configuration reference
- The API the plugin calls — for direct integrations and debugging: REST API reference