Supply-Chain Security for JVM Builds

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Ant 1.10.18 with Apache Ivy 2.6.0, Apache Maven 3.10.0 (3.9.16 where it differs, and Maven 4.0.0-rc-7, a release candidate, where named) and Gradle 9.8.1 (8.14.6 where it differs). Every example marked as run was executed on JDK 26 on 2026-10-16 in a scratch directory; output that comes from a run is shown as it was printed, with the long absolute path of the scratch directory shortened to <scratch> and the home directory to <home>. Anything that could not be run here is labelled "not run here" in the text. All identifiers (CVE numbers, advisory ids, versions and dates) were looked up on the vendors' own pages on the same day.

A build tool executes code from several parties before it compiles a single line of yours: the tool itself (and, for Gradle and Maven, its wrapper), the build files, the plugins, the libraries downloaded for the compile and runtime classpaths, and the repositories that serve all of them. A compromise anywhere in that chain becomes code running with the permissions of the developer or the CI job. This page follows the chain in the order a build meets it: trust model, tool distributions, wrappers, dependency integrity, credentials, SBOMs and, last, the security releases of 2026. The running scenario is tidepool, described on the section landing page; the dependency used in the examples is org.apache.commons:commons-csv:1.14.1. For how each tool resolves dependencies in the first place see Dependency Management Fundamentals and Repositories & Publishing Across Tools.

The trust model: a build file is code

None of the three tools sandboxes a build. A build.xml, a pom.xml (through its plugins) and a build.gradle(.kts) can all run arbitrary code on the machine that builds them, so each tool’s security position starts from the same sentence: only build projects whose build files, dependencies and repositories you trust.

Tool Stated position What follows from it

Ant

A build file can run any task, including exec, java and script, with the permissions of the user.

Reading a build.xml from a stranger is reading a program. The recent fixes (see the advisories below) concern tasks that fetch data from servers (ftp, scp) and archive handling.

Maven

The security page states that Maven assumes you trust the pom.xml and the code, dependencies and repositories used by the build, and that isolating untrusted builds is up to you.

Plugins bound to the lifecycle run during mvn verify. Maven 3.10 trims what a POM resolved from a repository may do (see Maven 3.10 hardening) but that does not make an untrusted project safe to build.

Gradle

The security best practices advise against running ./gradlew on untrusted projects, because the wrapper script and JAR, the settings file and the build scripts all execute code (opening such a project in an IDE triggers a sync that runs the same code).

Check the wrapper before running it (see Validating the wrapper JAR in CI), review the scripts, and use dependency verification so that what the scripts download is pinned.

The defences on the rest of this page are about tampering (a different file than the one you meant to get) and drift (a different file from yesterday’s), not about malicious-by-design code you decided to trust. The flow below is the common shape; each tool implements the stages with different mechanisms.

flowchart LR DL["Download: tool, wrapper, dependency"] --> CS["Checksum from the publisher (SHA-512 or SHA-256)"] CS --> SIG["Signature (PGP) against a key you already trust"] SIG --> TL["Trusted list kept in the repository: pinned checksums or keys"] TL --> CP["Classpath of the build"] CS -. "mismatch" .-> STOP["Build stops"] SIG -. "unknown key" .-> STOP TL -. "not in the list" .-> STOP

The weakest stage is the first one: a checksum served by the same server as the file proves the download was not corrupted, not that the server is honest. The checksum has to come over a different channel (the project’s web site for a tool, a file committed to your repository for a dependency) to protect against a compromised server. Every section below says which channel the checksum travels over.

Verifying tool distributions

Ant and Maven: SHA-512 and PGP

The Apache download pages publish, next to every archive, a .sha512 file, an .asc PGP signature and a KEYS file with the release managers' public keys. Both projects tell you to verify the signature and say the checksum is the weaker of the two checks; Ant’s page adds that you should take the KEYS and .asc files from the main distribution directory rather than from a mirror.

Ant 1.10.18 and Maven 3.10.0 publish the .sha512 file as the bare hexadecimal digest, with no file name, so the shasum -c mode needs the name added. The check below was run against the Maven 3.10.0 archive that this series downloaded and verified, and then against a copy with one byte changed:

echo "$(cat apache-maven-3.10.0-bin.tar.gz.sha512)  apache-maven-3.10.0-bin.tar.gz" | shasum -a 512 -c -
cp apache-maven-3.10.0-bin.tar.gz tampered.tar.gz
printf 'X' | dd of=tampered.tar.gz bs=1 seek=1000 conv=notrunc
echo "$(cat apache-maven-3.10.0-bin.tar.gz.sha512)  tampered.tar.gz" | shasum -a 512 -c -
apache-maven-3.10.0-bin.tar.gz: OK
tampered.tar.gz: FAILED
shasum: WARNING: 1 computed checksum did NOT match

The PGP step, as the Ant download page describes it, is gpg --import KEYS followed by gpg --verify of the .asc file (Ant’s own KEYS file is also inside its binary distribution, and gpg 2.2.41 was available here). The signature check itself was not run here, because this series downloaded only the archives and checksums. A signature proves the archive was produced by a holder of one of the listed keys, so the remaining question is how you came to trust KEYS: download it over HTTPS from downloads.apache.org, and compare key fingerprints with the Apache web of trust when the stakes justify it.

Gradle: SHA-256

Gradle publishes a SHA-256 checksum for each distribution on its release-checksums page and next to the file on services.gradle.org. The checksum file for 9.8.1 used here is gradle-9.8.1-bin.zip.sha256; the value of the distribution this series downloaded matched the one published for 9.8.1:

shasum -a 256 gradle-9.8.1-bin.zip
dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f  gradle-9.8.1-bin.zip

For the wrapper, Gradle ships the checking as a feature: the next section shows it. Gradle’s security best practices list both "validate the Gradle distribution SHA-256 checksum" and "validate the Gradle Wrapper on every upgrade" as recommended practices.

Distributions ship their own SBOMs

The Ant 1.10.18 binary distribution carries a CycloneDX SBOM for every JAR in lib, in JSON and XML next to the JAR, and the download page lists .cdx.json files next to each archive. The distribution unpacked here has 26 such JSON files.

ls apache-ant-1.10.18/lib | grep -c cdx.json
python3 -c "import json; d=json.load(open('apache-ant-1.10.18/lib/ant.cdx.json')); print(d['bomFormat'], d['specVersion']); print(d['metadata']['component']['name'], d['metadata']['component']['version'])"
26
CycloneDX 1.6
ant 1.10.18

Wrappers

A wrapper is a script and a small binary committed to your repository that downloads a specific tool version. It removes the "which Gradle or Maven did you install" problem, and it adds an attack surface: the files are executable code in your repository, and they download an archive from a URL. Ant has no wrapper; its installation is the unpacked archive, verified as above. The details of each wrapper are in Gradle Wrapper and Maven Wrapper; this section covers only the verification.

Gradle: distributionSha256Sum and the wrapper JAR

gradle-wrapper.properties accepts distributionSha256Sum. When it is set, the wrapper hashes the downloaded archive and refuses to continue on a mismatch. The wrapper task writes it when you pass --gradle-distribution-sha256-sum (a task option confirmed with gradle help --task wrapper on 9.8.1; the other options listed are --gradle-version, --gradle-distribution-url, --distribution-type, --validate-url, --network-timeout, --retries and --retry-back-off-ms).

The example uses a wrapper directory whose distributionUrl points to the local copy of the 9.8.1 archive (to avoid a second download) with a deliberately wrong checksum:

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=0000000000000000000000000000000000000000000000000000000000000000
distributionUrl=file\:<scratch>/tools/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=false
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
GRADLE_USER_HOME=$PWD/guh ./gradlew --version
Fetching distribution.
Downloading file:<scratch>/tools/gradle-9.8.1-bin.zip
..............10%..............20%...............30%..............40%...............50%..............60%...............70%..............80%...............90%..............100%
Exception in thread "main" java.lang.RuntimeException: Verification of Gradle distribution failed!

Your Gradle distribution may have been tampered with.
Confirm that the 'distributionSha256Sum' property in your gradle-wrapper.properties file is correct and you are downloading the wrapper from a trusted source.

Distribution Url: file:<scratch>/tools/gradle-9.8.1-bin.zip
Download Location: <scratch>/work/supply-chain/guh1/wrapper/dists/gradle-9.8.1-bin/b059gjp0p6cuxvj40w8csa0e5/gradle-9.8.1-bin.zip
Expected checksum: '0000000000000000000000000000000000000000000000000000000000000000'
Actual checksum:   'dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f'
Visit https://gradle.org/release-checksums/ to verify the checksums of official distributions. If your build uses a custom distribution, see with its provider.
	at org.gradle.wrapper.Install.verifyDownloadChecksum(SourceFile:2)
	at org.gradle.wrapper.Install.lambda$createDist$0(SourceFile:12)
	at org.gradle.wrapper.Install.createDist(SourceFile:24)
	at org.gradle.wrapper.GradleWrapperMain.lambda$prepareWrapper$0(SourceFile:2)
	at org.gradle.wrapper.GradleWrapperMain.main(SourceFile:2)

The official properties for 9.8.1 differ only in the two lines that point at https://services.gradle.org/distributions/ and in validateDistributionUrl=true. distributionSha256Sum protects the archive. It does not protect the other executable pieces in the repository, gradlew, gradlew.bat and gradle/wrapper/gradle-wrapper.jar, which run before any of that checking happens. The JAR’s own checksum is published by Gradle, and the 9.8.1 value matched the one in the scratch project:

shasum -a 256 gradle/wrapper/gradle-wrapper.jar
curl -s https://services.gradle.org/distributions/gradle-9.8.1-wrapper.jar.sha256
3b8a25775a69158b5ad2b1d17a80a88dc7b40a352a73eb27d06c612a7ce68e98  gradle/wrapper/gradle-wrapper.jar
3b8a25775a69158b5ad2b1d17a80a88dc7b40a352a73eb27d06c612a7ce68e98

Validating the wrapper JAR in CI

A pull request that touches gradle-wrapper.jar shows as "binary file not shown", which makes it a convenient place to hide code. The gradle/actions project provides two ways to check every gradle-wrapper.jar in the repository against the SHA-256 values of official Gradle releases (including look-alike file names built with homoglyphs). Since v4 the setup-gradle action does it on every run, so a separate step is only needed when you do not use that action. The latest release of the actions when this page was checked was v6.4.0 (2026-09-28). The workflow below is not run here; the action names and the v6 tag follow the project’s own documentation:

name: build
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: gradle/actions/setup-gradle@v6    # validates every gradle-wrapper.jar before building
      - run: ./gradlew build

Maven: distributionSha256Sum in maven-wrapper.properties

The Maven Wrapper (plugin 3.3.4, the version on Maven Central on the check date) generates .mvn/wrapper/maven-wrapper.properties. The file generated here with wrapper:wrapper -Dmaven=3.10.0 contained only the download location, no checksum:

wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zip

The generated mvnw script reads an optional distributionSha256Sum and compares it with sha256sum or shasum -a 256. Adding the property is a manual step (the value to use is the SHA-256 of the zip; neither the Maven download page nor Maven Central publishes one, both publish SHA-512, so compute it once from an archive you verified with the SHA-512 and PGP steps above). The run below used a local zip of the verified distribution so that nothing was downloaded; first with a wrong value, then with the right one. MAVEN_USER_HOME points the wrapper’s download cache to the scratch directory:

MAVEN_USER_HOME=$PWD/muh ./mvnw -v      # distributionSha256Sum=000...000
MAVEN_USER_HOME=$PWD/muh ./mvnw -v      # distributionSha256Sum=4136dc58...bfa7 (the zip's real SHA-256)
Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised.
If you updated your Maven version, you need to update the specified distributionSha256Sum property.
Apache Maven 3.10.0 (c43a36b8d67be7e0805a411bc0898af1a51f5472)
Maven home: <scratch>/work/supply-chain/muh/wrapper/dists/apache-maven-3.10.0/b5eb15c0
Java version: 26, vendor: Oracle Corporation, runtime: /Library/Java/JavaVirtualMachines/jdk-26.jdk/Contents/Home
Default locale: es_ES, platform encoding: UTF-8, time zone: Europe/Madrid
OS name: "mac os x", version: "27.0.1", arch: "aarch64", family: "mac"

Everything said above about the unprotected pieces applies to Maven too: mvnw is a shell script that runs before the checksum is checked, so review changes to it like code.

Dependency integrity

HTTPS only

Plain HTTP lets anyone between you and the repository replace an artifact. Both tools now refuse it by default, and the refusal is visible in a real error.

Maven. Since 3.8.1 (the fix for CVE-2021-26291, below) the default conf/settings.xml contains a blocked mirror for every external repository that uses HTTP. The relevant part of the file shipped in 3.10.0:

<mirror>
  <id>maven-default-http-blocker</id>
  <mirrorOf>external:http:*</mirrorOf>
  <name>Pseudo repository to mirror external repositories initially using HTTP.</name>
  <url>http://0.0.0.0/</url>
  <blocked>true</blocked>
</mirror>

A POM that declares an HTTP repository (http://repo.example.org/maven2, never contacted because it is blocked) and a dependency that is only there fails with the same message on Maven 3.10.0, 3.9.16 and 4.0.0-rc-7:

<repositories>
  <repository>
    <id>legacy</id>
    <url>http://repo.example.org/maven2</url>
  </repository>
</repositories>

<dependencies>
  <dependency>
    <groupId>org.example.legacy</groupId>
    <artifactId>legacy-lib</artifactId>
    <version>1.0</version>
  </dependency>
</dependencies>
[ERROR] Failed to execute goal on project tidepool-http: Could not collect dependencies for project org.example.tidepool:tidepool-http:jar:1.0.0-SNAPSHOT
[ERROR] Failed to read artifact descriptor for org.example.legacy:legacy-lib:jar:1.0
[ERROR] 	Caused by: The following artifacts could not be resolved: org.example.legacy:legacy-lib:pom:1.0 (absent): Could not transfer artifact org.example.legacy:legacy-lib:pom:1.0 from/to maven-default-http-blocker (http://0.0.0.0/): Blocked mirror for repositories: [legacy (http://repo.example.org/maven2, default, releases+snapshots)]

external:http:* (a selector available since Maven 3.8.0) matches all repositories using HTTP except those on localhost, which is why a local test server on http://localhost is not blocked. Fix the repository (use its HTTPS URL), do not delete the blocker.

Gradle. A repository with an http: URL is rejected unless you opt in with allowInsecureProtocol = true (isAllowInsecureProtocol in the Kotlin DSL). The build below uses http://localhost:8081/... — Gradle does not exempt localhost — and no opt-in:

Kotlin DSL
repositories {
    maven {
        name = "insecure"
        url = uri("http://localhost:8081/repository/releases")
    }
}
> Task :compileJava FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all dependencies for configuration ':compileClasspath'.
   > Using insecure protocols with repositories, without explicit opt-in, is unsupported.
* Try:
> Switch Maven repository 'insecure(http://localhost:8081/repository/releases)' to redirect to a secure protocol (like HTTPS) or allow insecure protocols.
> For more information, please refer to https://docs.gradle.org/9.8.1/dsl/org.gradle.api.artifacts.repositories.UrlArtifactRepository.html#org.gradle.api.artifacts.repositories.UrlArtifactRepository:allowInsecureProtocol in the Gradle documentation.
Groovy DSL
repositories {
    maven {
        name = 'insecure'
        url = uri('http://localhost:8081/repository/releases')
    }
}

The Groovy build fails with the same message (not re-run separately: the repository declaration is the only difference).

Ant and Ivy. Neither has a policy: the URL you write is the URL used. <get src="http://..."> and an Ivy ibiblio resolver with an http root work without complaint, so the only defence is to write https and to review every repository URL in ivysettings.xml and build.xml. Ant 1.10.16 added TLS identity checks for the mail task, and 1.10.18 changed the ftp default (below); verifying what you fetched (next section) is your build’s job.

Checksums from the repository: integrity, not authenticity

Every repository publishes checksums next to its files. For commons-csv-1.14.1.jar on Maven Central the files .sha1, .md5 and .asc exist and .sha256 and .sha512 returned 404 on the check date (other artifacts can differ, so look before relying on an algorithm). The tools differ in how strictly they use these files:

Tool Default behaviour Stronger setting

Ant (get, checksum)

The get task has no checksum attribute; verification is your build’s job.

The checksum task with verifyProperty (example below).

Ivy 2.6.0

ivy.checksums defaults to sha1,md5 (seen in the run below); a resolver’s checksums attribute overrides it.

SHA-256, SHA-384 or SHA-512 (Java algorithm names) are accepted, but a missing checksum file is skipped, and Central publishes none under those names (the run below).

Maven (Resolver)

aether.checksums.checksumAlgorithms defaults to SHA-1,MD5. On a mismatch Maven 3.10.0 only warns (Could not validate integrity of download) and the build succeeds; Maven 4.0.0-rc-7 fails it. --lax-checksums (-c) forces the warning.

--strict-checksums (-C) fails the build on a mismatch, and the trusted-checksums post-processor (below) pins files to values you hold.

Gradle

Nothing is pinned by default.

Dependency verification (below), which compares against a file in your repository.

Both behaviours were checked here against a file: repository whose .jar.sha1 was deliberately wrong: Maven 3.10.0 logged the ChecksumFailureException as a warning, resolved the JAR and printed BUILD SUCCESS, while Maven 4.0.0-rc-7 stopped with Checksum validation failed and BUILD FAILURE. So on Maven 3 only --strict-checksums turns these checks into something that stops a corrupted download. Even then, because a repository that serves a tampered JAR can serve a matching checksum, repository checksums catch corruption, not tampering. The two layers that follow compare against values the repository does not control.

Ant: verify what you fetch. The target below downloads a JAR and the SHA-1 file Central publishes next to it, and fails the build when they differ. It was run with Ant 1.10.18; the second target changes one byte of a copy to show the failure. The check only proves the download was not damaged, since both files came from the same server:

<project name="verify-download" default="verify">

  <property name="central" value="https://repo1.maven.org/maven2"/>
  <property name="path" value="org/apache/commons/commons-csv/1.14.1"/>
  <property name="jar" value="commons-csv-1.14.1.jar"/>

  <target name="verify" description="Downloads a JAR and checks it against the SHA-1 published next to it">
    <mkdir dir="build/download"/>
    <get src="${central}/${path}/${jar}" dest="build/download/${jar}" skipexisting="true"/>
    <get src="${central}/${path}/${jar}.sha1" dest="build/download/${jar}.sha1" skipexisting="true"/>
    <loadfile srcFile="build/download/${jar}.sha1" property="expected.sha1">
      <filterchain><striplinebreaks/></filterchain>
    </loadfile>

    <checksum file="build/download/${jar}" algorithm="SHA-1"
              property="${expected.sha1}" verifyProperty="sha1.ok"/>
    <fail message="SHA-1 mismatch for ${jar}">
      <condition><isfalse value="${sha1.ok}"/></condition>
    </fail>
    <echo message="${jar}: SHA-1 matches (${expected.sha1})"/>
  </target>

  <target name="tamper" depends="verify" description="Shows what a failing check looks like">
    <copy file="build/download/${jar}" tofile="build/download/tampered.jar"/>
    <concat destfile="build/download/tampered.jar" append="true">x</concat>
    <checksum file="build/download/tampered.jar" algorithm="SHA-1"
              property="${expected.sha1}" verifyProperty="tampered.ok"/>
    <fail message="SHA-1 mismatch for tampered.jar">
      <condition><isfalse value="${tampered.ok}"/></condition>
    </fail>
  </target>

</project>
verify:
    [mkdir] Created dir: <scratch>/work/supply-chain/ant-checksum/build/download
      [get] Getting: https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.jar
      [get] To: <scratch>/work/supply-chain/ant-checksum/build/download/commons-csv-1.14.1.jar
      [get] Getting: https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.jar.sha1
      [get] To: <scratch>/work/supply-chain/ant-checksum/build/download/commons-csv-1.14.1.jar.sha1
     [echo] commons-csv-1.14.1.jar: SHA-1 matches (467354980fade8528b6a9f9bdf7f4f19ab9b3373)

BUILD SUCCESSFUL
Total time: 0 seconds

tamper:
     [copy] Copying 1 file to <scratch>/work/supply-chain/ant-checksum/build/download

BUILD FAILED
<scratch>/work/supply-chain/ant-checksum/build.xml:28: SHA-1 mismatch for tampered.jar

Total time: 0 seconds

For a check that does protect you, put the expected value in the repository (a checksums.properties file loaded with <property file=...>) instead of downloading it from the server you are checking.

Source: Ant: Get task

Ivy: checksum algorithms. The resolver in ivysettings.xml lists the algorithms to check. With the default, the -d output of an ivy:retrieve run shows each file checked:

<ivysettings>
  <settings defaultResolver="central"/>
  <caches defaultCacheDir="${basedir}/build/ivy-cache"/>
  <resolvers>
    <ibiblio name="central" m2compatible="true" root="https://repo1.maven.org/maven2/"
             checksums="sha1"/>
  </resolvers>
</ivysettings>
[ivy:retrieve] setting 'ivy.checksums' to 'sha1,md5'
[ivy:retrieve] sha1 file found for https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.pom: checking...
[ivy:retrieve] 	central: downloading https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.pom.sha1
[ivy:retrieve] sha1 OK for https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.pom

Asking Ivy for a stronger algorithm needs the Java algorithm name. With the lowercase checksums="sha256" the resolution fails:

[ivy:retrieve] java.lang.IllegalArgumentException: Unknown checksum algorithm: sha256
[ivy:retrieve] 	[FAILED     ] org.apache.commons#commons-csv;1.14.1!commons-csv.pom(pom.original): Unknown checksum algorithm: sha256 (28ms)

checksums="SHA-512" (and likewise SHA-256) is accepted, and Ivy 2.6.0 then asks for a file named after the algorithm, .pom.SHA-512 and .jar.SHA-512. Central has no such files, and a checksum file that is not found is not an error, so the same ivy:retrieve -d run ends in BUILD SUCCESSFUL with nothing verified (no OK for line in the whole log; trimmed to the commons-csv lines):

[ivy:retrieve] 	central: downloading https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.pom
[ivy:retrieve] HTTP response status: 404 url=https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.pom.SHA-512
[ivy:retrieve] CLIENT ERROR: Not Found url=https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.pom.SHA-512
…
[ivy:retrieve] 	central: downloading https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.jar
[ivy:retrieve] HTTP response status: 404 url=https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.jar.SHA-512
[ivy:retrieve] CLIENT ERROR: Not Found url=https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.jar.SHA-512
…
[ivy:retrieve] 	3 artifacts copied, 0 already retrieved (976kB/4ms)
…
BUILD SUCCESSFUL

So against Maven Central the practical options with Ivy are SHA-1 and MD5 from the repository, plus your own <checksum> verification of the files you retrieve; a SHA-256 or SHA-512 list only helps with a repository that publishes files under those names. Ivy can also sign artifacts it publishes (the resolver signer attribute, since 2.2); the settings reference describes signers only for publication.

Source: Ivy: signers

Maven: trusted checksums

The Maven Resolver can pin every resolved artifact to a checksum you hold. The post-processor has two modes: record, run once in an environment you trust, writes a file of SHA-512 values; and verify, the normal mode, compares every resolved file with the file and fails the build on a mismatch or, with failIfMissing, on an unknown artifact. Commit the file to the repository so that the pinning survives a poisoned local repository or repository manager. The feature exists in Maven 3.9.x (Resolver 1.9) and in 3.10 and 4; this run used 3.10.0.

.mvn/maven.config of the tidepool-engine project (one option per line; the ${session.rootDirectory} expression needs Maven 3.9 or later):

--strict-checksums
-Daether.checksums.checksumAlgorithms=SHA-512,SHA-1,MD5
-Daether.trustedChecksumsSource.summaryFile=true
-Daether.trustedChecksumsSource.summaryFile.basedir=${session.rootDirectory}/.mvn/checksums/
-Daether.artifactResolver.postProcessor.trustedChecksums=true
-Daether.artifactResolver.postProcessor.trustedChecksums.checksumAlgorithms=SHA-512
-Daether.artifactResolver.postProcessor.trustedChecksums.failIfMissing=true

The POM declares commons-csv:1.14.1 and pins the compiler and resources plugins. Recording against an empty local repository (-Dmaven.repo.local points to a scratch directory so that nothing but the build’s own downloads is recorded):

mvn -B -ntp -Dmaven.repo.local=m2repo -Daether.artifactResolver.postProcessor.trustedChecksums.record=true clean compile
[INFO] Saving 100 checksums to '<scratch>/work/supply-chain/maven-trust/.mvn/checksums/checksums-central-8fac3ebd6edbaca3c794783fa38088af0aa128e7.sha512'

The file has one line per resolved artifact, SHA-512 first and the path relative to the local repository second (100 lines here, covering the plugins and their dependencies as well as the project’s own dependency). A normal build with a fresh local repository passes (BUILD SUCCESS). Changing the first two hexadecimal digits of the commons-csv-1.14.1.jar line makes the build fail, with both values in the message:

[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal on project tidepool-engine: Could not resolve dependencies for project org.example.tidepool:tidepool-engine:jar:1.0.0-SNAPSHOT
[ERROR] dependency: org.apache.commons:commons-csv:jar:1.14.1 (compile)
[ERROR] 	Could not transfer artifact org.apache.commons:commons-csv:jar:1.14.1 from/to central (https://repo.maven.apache.org/maven2): Checksum validation failed, expected '004fb9c4895532dd9f6cd448dfb4b35bfe9c4a81afc61e5bb13121f5142330ae97447200022b03988967a7cb89d3f48ce76e253238eb9f0bea57c09b352ac27b' (PROVIDED) but is actually 'f64fb9c4895532dd9f6cd448dfb4b35bfe9c4a81afc61e5bb13121f5142330ae97447200022b03988967a7cb89d3f48ce76e253238eb9f0bea57c09b352ac27b'

(PROVIDED) is the Resolver’s name for the checksum strategy that came from your file rather than from the repository. The Resolver also has a sparse-directory source (one checksum file per artifact, in a tree that mirrors the local repository) for repositories where one summary file per remote repository would be unwieldy; the Resolver documentation compares the two sources. Trusted checksums are a Maven-side equivalent of Gradle’s dependency verification (next), with SHA-512 and no signature support.

Maven: remote repository filtering

When a build lists several repositories, Maven asks them in order, so a repository that should never serve a given artifact can serve it first (dependency confusion), and every miss leaks the coordinates you build to that repository’s operator. Remote repository filtering (RRF) tells the Resolver which repository may serve which artifact. The Resolver 2 documentation, which Maven 3.10 and 4 use, describes two filters: prefixes, a list of path prefixes that the repository itself publishes, discovered automatically; and groupId, a file you write. The prefixes filter is why a Maven 3.10 build against Central prints a line like this on the first resolution:

[INFO] Loaded 24109 auto-discovered prefixes for remote repository central (prefixes-central.txt)

A groupId file for the repository central is groupId-central.txt in the filter directory (set here with -Daether.remoteRepositoryFilter.groupId.basedir=${session.rootDirectory}/.mvn/rrf/ in .mvn/maven.config). A line is a group and everything below it; ! negates, = limits to exactly that group, and * sets the default to "accept". This file blocks the Apache Commons group on Central and allows everything else:

# everything on Central except Apache Commons
*
!org.apache.commons

Building tidepool-engine, which depends on commons-csv, with a fresh local repository on Maven 3.10.0:

[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal on project tidepool-engine: Could not resolve dependencies for project org.example.tidepool:tidepool-engine:jar:1.0.0-SNAPSHOT
[ERROR] dependency: org.apache.commons:commons-csv:jar:1.14.1 (compile)
[ERROR] 	groupId: G:org.apache.commons NOT allowed from central

In real use the file lists the groups a repository is allowed to serve (for an internal repository, your own group ids), and Central is left alone: the Resolver documentation warns that limiting what can come from Central is risky because plugins and their dependencies come from it too. The Resolver can also record the group ids it meets (-Daether.remoteRepositoryFilter.groupId.record=true) as a starting point for such a file.

On Maven 3.9.16 (Resolver 1.9) the same file behaved differently: with no extra option the build passed (no filtering happened), and with -Daether.remoteRepositoryFilter.groupId=true it failed on the first plugin, saying that G:org.apache.maven.plugins was not allowed from central — the * default-accept line did not have the effect it has on 3.10. This page therefore uses 3.10 for the example; on 3.9.x enable the filter explicitly and list every group allowed.

Gradle: dependency verification

Gradle’s feature is the most complete of the three. A file gradle/verification-metadata.xml lists the checksums (SHA-256 and others) and the PGP keys that are acceptable for each artifact; Gradle checks every dependency, plugin and metadata file it downloads against it, fails the build on a mismatch, and by default verifies the metadata files (.pom, .module) as well. The examples below were run on Gradle 9.8.1 only. The modes are --dependency-verification strict (default), lenient (report but do not fail) and off.

Generating the file

The file is bootstrapped by running a build with --write-verification-metadata. With sha256 alone, every artifact gets a checksum entry. This is the Kotlin DSL build of the scratch project (a Groovy build file differs only in syntax and is irrelevant to the verification):

Kotlin DSL
plugins {
    `java-library`
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.apache.commons:commons-csv:1.14.1")
}
Groovy DSL
plugins {
    id 'java-library'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.apache.commons:commons-csv:1.14.1'
}
gradle --write-verification-metadata sha256 compileJava

The generated gradle/verification-metadata.xml (header and long XML schema attribute trimmed):

<verification-metadata xmlns="https://schema.gradle.org/dependency-verification" ...>
   <configuration>
      <verify-metadata>true</verify-metadata>
      <verify-signatures>false</verify-signatures>
   </configuration>
   <components>
      <component group="commons-codec" name="commons-codec" version="1.19.0">
         <artifact name="commons-codec-1.19.0.jar">
            <sha256 value="5c3881e4f556855e9c532927ee0c9dfde94cc66760d5805c031a59887070af5f" origin="Generated by Gradle"/>
         </artifact>
         <artifact name="commons-codec-1.19.0.pom">
            <sha256 value="e0f3269fa23de0c83130c5659f5f9514cc5422c0bcdf45f2eae004a78b9fca34" origin="Generated by Gradle"/>
         </artifact>
      </component>
      <component group="org.apache.commons" name="commons-csv" version="1.14.1">
         <artifact name="commons-csv-1.14.1.jar">
            <sha256 value="32be0e1e76673092f5d12cb790bd2acb6c2ab04c4ea6efc69ea5ee17911c24fe" origin="Generated by Gradle"/>
         </artifact>
         <artifact name="commons-csv-1.14.1.pom">
            <sha256 value="95df735302c0ab92cec05a2b51e2270758e15f0a64186de65662eb148ebd97fd" origin="Generated by Gradle"/>
         </artifact>
      </component>
      ... (commons-io, apache, commons-parent and junit-bom entries omitted)
   </components>
</verification-metadata>

(The junit-bom entry arrives through the POM of commons-csv: dependency verification also covers the metadata that Gradle reads, not only the JARs on the classpath.)

Changing the first two hexadecimal digits of the commons-csv-1.14.1.jar checksum and building again fails, naming the artifact and the repository it came from:

> Task :compileJava FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Dependency verification failed for configuration ':compileClasspath'
  One artifact failed verification: commons-csv-1.14.1.jar (org.apache.commons:commons-csv:1.14.1) from repository MavenRepo
  This can indicate that a dependency has been compromised. Please carefully verify the checksums.

  Open this report for more details: file://<scratch>/work/supply-chain/gradle-verify/build/reports/dependency-verification/at-1792142010624/dependency-verification-report.html

Signatures

Checksums alone say "this is the file I saw when I generated the metadata" and change with every upgrade. Signatures say "this file was signed by a key I trust", which scales better: you trust the key of a project and every release it signs verifies. Generating with pgp (and sha256 as the fallback for artifacts without a trusted signature) writes <verify-signatures>true</verify-signatures>, a <trusted-keys> block with the full 40-character fingerprint of every signing key and the groups it may sign, and per-artifact <pgp> entries for the exceptions. The command was gradle --write-verification-metadata sha256,pgp compileJava, and the result for the same project was:

<configuration>
   <verify-metadata>true</verify-metadata>
   <verify-signatures>true</verify-signatures>
   <trusted-keys>
      <trusted-key id="2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB">
         <trusting group="commons-codec"/>
         <trusting group="commons-io"/>
         <trusting group="org.apache.commons"/>
      </trusted-key>
      <trusted-key id="FF6E2C001948C5F2F38B0CC385911F425EC61B51" group="org.junit" name="junit-bom" version="5.13.1"/>
   </trusted-keys>
</configuration>
<components>
   <component group="org.apache" name="apache" version="35">
      <artifact name="apache-35.pom">
         <pgp value="84789D24DF77A32433CE1F079EB80E92EB2135B1"/>
      </artifact>
   </component>
</components>

Gradle downloaded the signature files and looked up the public keys while generating this. The file then verifies on a later run with --refresh-dependencies (so everything is downloaded again). Removing the <trusting group="org.apache.commons"/> line makes the signatures of three artifacts untrusted and the build stops:

> Dependency verification failed for configuration ':compileClasspath'
  3 artifacts failed verification:
    - commons-csv-1.14.1.jar (org.apache.commons:commons-csv:1.14.1) from repository MavenRepo
    - commons-csv-1.14.1.pom (org.apache.commons:commons-csv:1.14.1) from repository MavenRepo
    - commons-parent-85.pom (org.apache.commons:commons-parent:85) from repository MavenRepo
  If the artifacts are trustworthy, you will need to update the gradle/verification-metadata.xml file. For more on how to do this, please refer to https://docs.gradle.org/9.8.1/userguide/dependency_verification.html#sec:troubleshooting-verification in the Gradle documentation.

(The anchor sec:troubleshooting-verification named in that message does not exist on the 9.8.1 documentation page; the section about failures is sec:dealing-verification-failure.)

Trusting a key is a decision, not a formality: the key id in an error message comes from the signature file itself, so Gradle cannot tell you the signer is the project you think it is. Check the fingerprint against the project’s published keys before you add it. To avoid depending on key servers on every machine, export the keys used into a keyring file (gradle/verification-keyring.keys or .gpg) with --export-keys and commit one of the two formats. The documentation also covers <ignored-key> for keys that are not available, <trusting> regular expressions, sha256 fallbacks for artifacts that are not signed, a dry mode for checking an update, and the report that the failure message points to.

Verification is not a replacement for choosing the right repositories: the three 2026 advisories about repositories that Gradle failed to disable (below) all name strict repository content filtering and dependency verification as the mitigation, which is why the two features are described together in Dependency Locking, Verification & Caching and Repositories & Content Filtering.

Signatures with Maven and Ant

Maven itself does not verify PGP signatures of dependencies. Central requires signatures on published artifacts (see the Central requirements and Publishing to Maven Central), but the consumer side is up to extensions. A third-party plugin exists for that (the PGPVerify Maven plugin, a separate project that this series did not run); the first-party answer for Maven is the trusted-checksums mechanism above. Ant and Ivy have no built-in signature verification either, so for them verify with your own gpg --verify step in the build or in CI.

Credentials

A build that publishes or reads from a private repository needs a secret. The rules are the same for all three tools: keep it out of the repository and out of logs, scope it to the repository that needs it, and prefer a short-lived token supplied by CI to a long-lived password in a file.

Maven: settings.xml

Credentials live in ~/.m2/settings.xml <server> entries matched to a repository by <id>. Three Maven-specific layers protect them:

  • Password encryption. Maven 3 encrypts passwords with a master password that lives in ~/.m2/settings-security.xml. The Maven guide itself calls this obfuscation-grade (single-hash key derivation, no authenticated encryption): it keeps secrets out of logs and screenshots but does not resist an attacker who can read your files. Creating an encrypted value (the argument is a placeholder; real use lets Maven prompt):

    mvn --encrypt-master-password 'placeholder-master'
    {O0dVSUFOQRoHGc8COO+KVkNwfTAqd2KRQwc0mgRxmNtgyB90AdmIp8ohTk9vMtFv}

    The value differs on every run (Maven salts it), and a <server> password written as {...} is decrypted at use time.

  • Maven 4 (release candidate). Replaces this with mvnenc and pluggable "dispatchers", AES/GCM with a PBKDF2 key, and master-key sources including a file, an environment variable, a system property, the GnuPG agent, Pinentry and the 1Password CLI. Setup is mvnenc init, which is interactive and not run here; the diagnosis command can be run without it and shows what an unconfigured installation looks like:

    mvnenc -B diag
    [INFO] Configuration validation of MavenSecDispatcher: INVALID
    [INFO]   Legacy dispatcher present in system
    [ERROR]   No configuration file found on path <home>/.m2/settings-security4.xml
    [INFO]   Configuration validation of LegacyDispatcher: INVALID
    [ERROR]     Legacy configuration not found or does not contains encrypted master password

    (One more line, a warning that the legacy dispatcher is not operational, is omitted from this output. mvnenc encrypt without a configuration ends with "Maven Encryption is not configured, run mvnenc init first.")

  • Origin-scoped credentials (Maven 3.10). Before 3.10 a <server> could be used for any repository that happened to share its id. In 3.10 the credentials of a server are bound to the origin (scheme, host and port) of the repositories and mirrors declared with the same id, the id is matched exactly instead of by prefix, and <aliases> lets one set of credentials serve several ids. When the repository is not known at session start (for example one added by a profile activated with -P), <repositoryOrigins> declares the origins explicitly, written as scheme://host[:port]. This was not run here; the settings below follow the 3.10.0 settings reference, with environment variables supplying the secret:

    <settings>
      <servers>
        <server>
          <id>internal</id>
          <username>${env.REPO_USER}</username>
          <password>${env.REPO_TOKEN}</password>
          <aliases>
            <alias>internal-snapshots</alias>
          </aliases>
          <repositoryOrigins>
            <repositoryOrigin>https://repo.example.org</repositoryOrigin>
          </repositoryOrigins>
        </server>
      </servers>
    </settings>

Maven 3.10 added full settings.xml interpolation, which is what makes ${env.REPO_TOKEN} work for all values.

Gradle: credentials as properties

Declare the credentials by type and let Gradle look the values up from properties named after the repository. For a repository called internal the properties are internalUsername and internalPassword, which come from ~/.gradle/gradle.properties, the command line (-P) or, in CI, environment variables named ORG_GRADLE_PROJECT_internalUsername and ORG_GRADLE_PROJECT_internalPassword. The build file contains no secret and no property is read until the repository is used:

Kotlin DSL
repositories {
    maven {
        name = "internal"
        url = uri("https://localhost:8443/repository/releases")
        credentials(PasswordCredentials::class)
    }
    mavenCentral()
}
Groovy DSL
repositories {
    maven {
        name = 'internal'
        url = uri('https://localhost:8443/repository/releases')
        credentials(PasswordCredentials)
    }
    mavenCentral()
}

Without the properties, both builds stop with a message that names them:

* What went wrong:
Execution failed for task ':compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> The following Gradle properties are missing for 'internal' credentials:
    - internalUsername
    - internalPassword

With placeholder values supplied through the environment and a local server on port 8443 that answers in plain text (so the TLS handshake fails, localhost only), the build does not fall through to the second repository. It fails on the first one, which is the behaviour the security fix in Gradle 9.8.1 guarantees (see the advisories below):

> Could not resolve org.apache.commons:commons-csv:1.14.1.
   > Could not get resource 'https://localhost:8443/repository/releases/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.pom'.
      > Could not HEAD 'https://localhost:8443/repository/releases/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.pom'.
         > Unsupported or unrecognized SSL message

The older behaviour (continuing with mavenCentral() after such an error) could not be run here, because Gradle versions before the fix were not installed.

Gradle 9.8.0 added reuse of Maven mirror settings (org.gradle.mirror.maven.settings=true), so a single internal mirror configuration in settings.xml serves both tools; the credentials-by-property mechanism above is unaffected.

Ant and Ivy

Ant has no credential store: the get task takes username and password attributes, and tasks such as scp take their own, so pass them as properties from the command line (-Drepo.password=...) or from a properties file outside the repository, never literals in build.xml. Ivy settings declare a credentials element with host, realm, username and passwd attributes, and the file that holds them should not be committed either.

SBOMs

A software bill of materials (SBOM) lists what a build contains, so that a later advisory can be matched against it. CycloneDX is the format used by all three ecosystems' tooling below. The three tools approach it differently: Gradle and Maven can discover the dependency graph, Ant only records what you write down (or what Ivy knows).

Ant: CycloneDX Antlib 0.2

The Antlib (released 2026-09-25, on Maven Central as org.apache.ant:ant-cyclonedx) provides a componentbom task and the types component, license, organization and others; it needs Ant 1.10.x (not guaranteed before 1.10.18) and Java 9 or later at run time, creates CycloneDX 1.7 by default, and can read Ivy files through an ivyModule resolver. For a project with manual dependency management you list the components yourself. The Antlib and its dependencies (CycloneDX Core Java, Jackson and others, 13 JARs) were fetched into a lib directory with the Maven dependency plugin from the ant-cyclonedx coordinates, then used with ant -lib lib:

<project name="tidepool-engine" default="sbom" xmlns:cdx="antlib:org.apache.ant.cyclonedx">

  <property name="version" value="1.0.0"/>

  <target name="sbom" description="Writes a CycloneDX SBOM for tidepool-engine">
    <mkdir dir="${basedir}/build/sbom"/>

    <cdx:component id="commons-csv" name="commons-csv" group="org.apache.commons"
                   version="1.14.1" unknownDependencies="true"/>

    <cdx:componentbom bomName="tidepool-engine-bom" format="json"
                      outputDirectory="${basedir}/build/sbom">
      <component name="tidepool-engine" group="org.example.tidepool"
                 version="${version}" description="Tide height engine">
        <dependency componentRef="commons-csv"/>
      </component>
      <additionalComponent refid="commons-csv"/>
    </cdx:componentbom>
  </target>

</project>

The outputDirectory must exist (a first attempt without the mkdir failed with "outputDirectory must point to a directory"), and exactly one nested component is the subject of the SBOM, with further components declared through additionalComponent. Running it and listing the package URLs:

ant -noinput -lib lib sbom
grep -E '"(bomFormat|specVersion)"' build/sbom/tidepool-engine-bom.json
grep -E '"purl"' build/sbom/tidepool-engine-bom.json | sort -u
[cdx:componentbom] creating CycloneDX SBOM <scratch>/work/supply-chain/ant-sbom/build/sbom/tidepool-engine-bom.json

BUILD SUCCESSFUL
Total time: 0 seconds
  "bomFormat" : "CycloneDX",
  "specVersion" : "1.7",
          "purl" : "pkg:maven/org.apache.ant/ant-cyclonedx@0.2?type=jar",
      "purl" : "pkg:maven/org.apache.commons/commons-csv@1.14.1?type=jar",
      "purl" : "pkg:maven/org.example.tidepool/tidepool-engine@1.0.0?type=jar"

The first purl is the Antlib itself, recorded in the SBOM’s metadata.tools section. The SBOM contains commons-csv because the build file says so, not because anything scanned the classpath: with Ant the SBOM is only as accurate as the list you maintain, or as the Ivy descriptor you point it at.

Maven: CycloneDX Maven plugin

The CycloneDX Maven plugin is a third-party project (2.9.3 on Maven Central on the check date). makeBom can be run without touching the POM:

mvn org.cyclonedx:cyclonedx-maven-plugin:2.9.3:makeBom
[INFO] --- cyclonedx:2.9.3:makeBom (default-cli) @ tidepool-engine ---
[INFO] CycloneDX: Writing and validating BOM (XML): <scratch>/work/supply-chain/maven-sbom/target/bom.xml
[INFO]            attaching as tidepool-engine-1.0.0-SNAPSHOT-cyclonedx.xml
[INFO] CycloneDX: Writing and validating BOM (JSON): <scratch>/work/supply-chain/maven-sbom/target/bom.json
[INFO]            attaching as tidepool-engine-1.0.0-SNAPSHOT-cyclonedx.json
[INFO] BUILD SUCCESS
  "bomFormat" : "CycloneDX",
  "specVersion" : "1.6",
      "purl" : "pkg:maven/commons-codec/commons-codec@1.19.0?type=jar",
      "purl" : "pkg:maven/commons-io/commons-io@2.20.0?type=jar",
      "purl" : "pkg:maven/org.apache.commons/commons-csv@1.14.1?type=jar",
      "purl" : "pkg:maven/org.example.tidepool/tidepool-engine@1.0.0-SNAPSHOT?type=jar"

(Two warnings about unknown JSON-schema keywords, printed by the plugin’s validator between the JSON lines, are omitted from this output; they did not affect the result.) Both transitive dependencies of commons-csv appear, which the Ant SBOM above does not list because nothing told it about them. To produce the SBOM on every build, bind the goal to a phase in the POM; the attaching as lines show that the files are attached to the project and therefore installed and deployed with it. A multi-module project uses makeAggregateBom.

Source: CycloneDX

Gradle: CycloneDX Gradle plugin

The CycloneDX Gradle plugin (plugin id org.cyclonedx.bom, 3.5.1 on the Gradle Plugin Portal on the check date, also third party) adds a cyclonedxBom task. Setting the project’s group and version avoids the plugin’s warning that it will otherwise use "unspecified".

Kotlin DSL
plugins {
    `java-library`
    id("org.cyclonedx.bom") version "3.5.1"
}

group = "org.example.tidepool"
version = "1.0.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.apache.commons:commons-csv:1.14.1")
}
Groovy DSL
plugins {
    id 'java-library'
    id 'org.cyclonedx.bom' version '3.5.1'
}

group = 'org.example.tidepool'
version = '1.0.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.apache.commons:commons-csv:1.14.1'
}
gradle cyclonedxBom
grep -E '"(bomFormat|specVersion)"' build/reports/cyclonedx/bom.json
grep -E '"purl"' build/reports/cyclonedx/bom.json | sort -u
> Task :cyclonedxDirectBom
> Task :cyclonedxBom
BUILD SUCCESSFUL in 13s
  "bomFormat" : "CycloneDX",
  "specVersion" : "1.6",
      "purl" : "pkg:maven/commons-codec/commons-codec@1.19.0?type=jar",
      "purl" : "pkg:maven/commons-io/commons-io@2.20.0?type=jar",
      "purl" : "pkg:maven/org.apache.commons/commons-csv@1.14.1?type=jar",
      "purl" : "pkg:maven/org.example.tidepool/tidepool-engine@1.0.0-SNAPSHOT?project_path=%3A",

The Groovy DSL build produced bom.json and bom.xml in the same directory (checked with ls; its contents were not compared). The resolution is that of the build’s runtime classpath, so the transitive dependencies are listed; whichever tool creates it, an SBOM generated at build time from the resolved graph is more trustworthy than one maintained by hand.

The security releases of 2026

The table lists the 2026 fixes in the tools themselves that this series targets, and the identifier and affected versions as each vendor states them. Use it to decide whether an upgrade is overdue; the first column names the release that contains the fix, which is always the baseline of this section or later.

Fixed in Identifier What was wrong What changed

Ant 1.10.18 (2026-09-06)

CVE-2026-78254, rated Moderate

The ftp and scp tasks could be made to write outside the download directory by relative paths sent by a malicious (or machine-in-the-middle) server.

allowFilesToEscapeDest on ftp and scp is false by default; ftp useSecureDataChannel is true by default, so ftps also encrypts the data channel. Set the first attribute to true only if you rely on the old behaviour.

Ant 1.10.19 (not released on 2026-10-16)

CVE-2026-94114 (in Apache Commons BCEL)

BCEL, the bytecode library, cached attacker-supplied classes under the name they declared, without validating the requested one.

The Ant source tree’s change log lists "Updated BCEL dependency to 6.13.0 because of CVE-2026-94114" under 1.10.19, which had not been released on the check date. The 1.10.18 distribution contains the optional ant-apache-bcel library.

Ivy 2.6.0 (July 2026)

CVE-2026-26032, rated Medium

The PackagerResolver built a working directory from module coordinates, so coordinates containing ../ could break out of the configured buildRoot and overwrite other files. An attacker needs access to a packager repository.

Fixed in 2.6.0. Only builds that use the packager resolver are exposed.

Maven 3.10.0 (2026-09-27)

No CVE on the Maven security page

Hardening listed in the release notes (next section).

See below.

Gradle 9.8.1 and 8.14.6 (October 2026)

Three advisories rated High (CVSS v4 7.3 to 8.6): GHSA-mvvg-497x-hmj8, GHSA-xwqc-3h47-hg64 and GHSA-j5m7-59rp-24f5. No CVE number was listed on them when checked.

The daemon accepted Java-serialized objects from its local TCP channels before authenticating the client (client to daemon) or at all (worker to daemon), which can mean code execution in the daemon JVM or denial of service for an attacker who can reach the loopback port. Separately, a failed SSL connection to a repository did not disable that repository, so resolution could continue to the next one.

Upgrade to 9.8.1 or 8.14.6. The workarounds the advisories give are to avoid shared multi-user machines for builds, and to use strict repository content filtering and dependency verification for the SSL case.

Gradle 9.3.0 and 8.14.4 (earlier in 2026)

CVE-2026-22865 (GHSA-mqwm-5m85-gmcv) and CVE-2026-22816 (GHSA-w78c-w6vf-rw82), both High (8.6)

The same family: after retries on transient errors, or on an unknown host name, Gradle moved on to the next repository instead of failing, so a build could resolve a dependency from a repository it did not mean to use.

Fixed in 8.14.4 and 9.3.0; the SSL-failure case of the same behaviour was fixed later, in 9.8.1 and 8.14.6.

The 9.8.1 release notes call the three advisories "high rated" and note that 8.14.6 contains the same fixes; the advisories give 8.14.6 and 9.8.1 as the patched versions and "earlier than 8.14.6, and 9.0.0 to 9.8.0" as affected.

Maven 3.10 hardening

The Maven 3.10.0 release notes list these under "Security Hardening":

  • credentials of a settings <server> are scoped to the origin of the repositories and mirrors with the same id, and the server id is matched exactly (see Credentials above);

  • a model resolved from a repository (a dependency, parent or imported BOM) contributes less to the build: restricted interpolation, profile activation limited to platform-derived criteria, recessive repository merging and rejection of system-scoped dependencies, with the maven.model.dependencyInterpolation.full property as an opt-out;

  • repositories supplied by the request or the session keep precedence over the ones declared by a resolved model;

  • artifact and metadata coordinates, relocation coordinates and legacy metadata file name tokens are validated before use.

The same release raises POM validation (duplicate plugins or dependencies, invalid characters in versions and others now fail the build; -Dmaven.resolver.validation=OFF (upper case; off fails with No enum constant) relaxes the new coordinate validation for a migration), makes reproducible builds the default (the super POM sets project.build.outputTimestamp) and changes the classpath order to breadth-first (-Daether.system.dependencyVisitor=preOrder restores the old order). The validation and ordering changes are not security fixes but matter when you upgrade: see Maven Security & Reproducible Builds.

Earlier advisories still worth knowing

Identifier Tool and fix Why it matters today

CVE-2021-26291

Maven 3.8.1: <blocked> mirrors, the external:http:* selector and the default maven-default-http-blocker.

Explains why an HTTP repository fails with "Blocked mirror" (shown above). The Maven security page notes the issue was a vulnerability for builds that connect directly to remote repositories rather than to a repository manager.

CVE-2021-36373 and CVE-2021-36374

Ant 1.10.11 and 1.9.16: a specially crafted tar or zip-format archive (including JARs) could make a build allocate large amounts of memory and fail. Low.

Any Ant build that reads archives from outside sources should be on 1.10.11 or later; Ant 1.9 has been end of life since 2024.

CVE-2022-46751 (Ivy 2.5.2), CVE-2022-37865 and CVE-2022-37866 (Ivy 2.5.1)

Ivy: XML external entity injection when parsing its own configuration, Ivy files or POMs (DTD processing off by default since 2.5.2); archive extraction and cache path traversal. Medium.

Ivy 2.6.0 contains all three fixes plus the 2026 one; do not use Ivy 2.4 or 2.5.0 builds.

A hardening checklist

Concern Ant (and Ivy) Maven Gradle

Verify the tool

SHA-512 and PGP from the Ant download page.

SHA-512 and PGP from the Maven download page.

SHA-256 from the release-checksums page.

Pin the wrapper

Not applicable (no wrapper).

distributionSha256Sum in maven-wrapper.properties (manual).

distributionSha256Sum (set by wrapper --gradle-distribution-sha256-sum) and wrapper JAR validation in CI.

HTTPS only

Your own discipline.

Default HTTP blocker (since 3.8.1).

allowInsecureProtocol opt-in required.

Pin dependencies

Own checksum step; Ivy sha1,md5 only.

Trusted checksums (SHA-512), --strict-checksums.

verification-metadata.xml with checksums and PGP keys.

Limit where artifacts come from

Single trusted repository.

Remote repository filtering (prefixes automatic in 3.10; groupId file).

Repository content filtering and dependency verification.

Secrets

Properties from outside the repository.

settings.xml with encrypted or environment-supplied values; origin-scoped in 3.10.

credentials(PasswordCredentials::class) with properties or environment variables.

SBOM

CycloneDX Antlib 0.2; the Ant distribution ships its own.

CycloneDX Maven plugin (third party).

CycloneDX Gradle plugin (third party).

The practice that matters most is shared by the three columns: commit the files that pin trust (checksum file, verification metadata, wrapper properties), review changes to them as you would review code, and upgrade the build tool itself when a security release lands. For the tool-specific details see CI & IDE Integration for running these checks in pipelines, and Comparing Ant, Maven & Gradle for where security features fit among the other differences.