diff --git a/.github/scripts/VerifyMavenWrapperIntegrity.java b/.github/scripts/VerifyMavenWrapperIntegrity.java
new file mode 100644
index 00000000..075a3727
--- /dev/null
+++ b/.github/scripts/VerifyMavenWrapperIntegrity.java
@@ -0,0 +1,81 @@
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Properties;
+
+/**
+ * Fail-closed preflight for the Maven Wrapper distribution trust binding.
+ *
+ *
This source-file program executes with the JDK before Maven Wrapper bootstrap. It verifies
+ * that the repository still binds the reviewed Maven distribution URL to its reviewed SHA-256
+ * checksum. Maven Wrapper then verifies the downloaded archive against the same checksum.
+ */
+public final class VerifyMavenWrapperIntegrity {
+ private static final Path WRAPPER_PROPERTIES =
+ Path.of(".mvn", "wrapper", "maven-wrapper.properties");
+ private static final String EXPECTED_WRAPPER_VERSION = "3.3.4";
+ private static final String EXPECTED_DISTRIBUTION_TYPE = "only-script";
+ private static final String EXPECTED_DISTRIBUTION_URL =
+ "https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/"
+ + "apache-maven-3.9.11-bin.zip";
+ private static final String EXPECTED_DISTRIBUTION_SHA256 =
+ "0d7125e8c91097b36edb990ea5934e6c68b4440eef4ea96510a0f6815e7eeadb";
+
+ private VerifyMavenWrapperIntegrity() {
+ // Utility class.
+ }
+
+ /**
+ * Verifies the reviewed Maven Wrapper distribution binding and exits non-zero on drift.
+ *
+ * @param args ignored command-line arguments
+ * @throws IOException when the wrapper properties cannot be read
+ */
+ public static void main(String[] args) throws IOException {
+ if (!Files.isRegularFile(WRAPPER_PROPERTIES)) {
+ throw new IllegalStateException("Maven Wrapper properties file is missing");
+ }
+
+ List sourceLines = Files.readAllLines(WRAPPER_PROPERTIES, StandardCharsets.UTF_8);
+ Properties properties = new Properties();
+ try (Reader reader = Files.newBufferedReader(WRAPPER_PROPERTIES, StandardCharsets.UTF_8)) {
+ properties.load(reader);
+ }
+
+ requireUniqueProperty(sourceLines, "wrapperVersion");
+ requireUniqueProperty(sourceLines, "distributionType");
+ requireUniqueProperty(sourceLines, "distributionUrl");
+ requireUniqueProperty(sourceLines, "distributionSha256Sum");
+
+ requireExact(properties, "wrapperVersion", EXPECTED_WRAPPER_VERSION);
+ requireExact(properties, "distributionType", EXPECTED_DISTRIBUTION_TYPE);
+ requireExact(properties, "distributionUrl", EXPECTED_DISTRIBUTION_URL);
+ requireExact(properties, "distributionSha256Sum", EXPECTED_DISTRIBUTION_SHA256);
+
+ System.out.println("Maven Wrapper integrity preflight passed.");
+ }
+
+ private static void requireUniqueProperty(List sourceLines, String key) {
+ long matches = sourceLines.stream()
+ .map(String::trim)
+ .filter(line -> line.startsWith(key + "="))
+ .count();
+ if (matches != 1) {
+ throw new IllegalStateException(
+ "Expected exactly one canonical " + key + " property, found " + matches
+ );
+ }
+ }
+
+ private static void requireExact(Properties properties, String key, String expectedValue) {
+ String actualValue = properties.getProperty(key);
+ if (!expectedValue.equals(actualValue)) {
+ throw new IllegalStateException(
+ "Maven Wrapper integrity drift for " + key + ": expected reviewed value"
+ );
+ }
+ }
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c9780487..6484ce01 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,10 +38,18 @@ jobs:
java-version: "25"
cache: maven
+ - name: Verify Maven Wrapper integrity (Unix)
+ if: runner.os != 'Windows'
+ run: java .github/scripts/VerifyMavenWrapperIntegrity.java
+
- name: Run tests (Unix)
if: runner.os != 'Windows'
run: ./mvnw -B test
+ - name: Verify Maven Wrapper integrity (Windows)
+ if: runner.os == 'Windows'
+ run: java .github/scripts/VerifyMavenWrapperIntegrity.java
+
- name: Run tests (Windows)
if: runner.os == 'Windows'
run: .\\mvnw.cmd -B test
@@ -60,10 +68,18 @@ jobs:
java-version: "25"
cache: maven
+ - name: Verify Maven Wrapper integrity (Unix)
+ if: runner.os != 'Windows'
+ run: java .github/scripts/VerifyMavenWrapperIntegrity.java
+
- name: Run tests (Unix)
if: runner.os != 'Windows'
run: ./mvnw -B test
+ - name: Verify Maven Wrapper integrity (Windows)
+ if: runner.os == 'Windows'
+ run: java .github/scripts/VerifyMavenWrapperIntegrity.java
+
- name: Run tests (Windows)
if: runner.os == 'Windows'
run: .\\mvnw.cmd -B test
diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml
index 26aefe40..2504ff5a 100644
--- a/.github/workflows/sbom.yml
+++ b/.github/workflows/sbom.yml
@@ -34,6 +34,9 @@ jobs:
java-version: "25"
cache: maven
+ - name: Verify Maven Wrapper integrity
+ run: java .github/scripts/VerifyMavenWrapperIntegrity.java
+
- name: Generate CycloneDX SBOM (aggregate)
run: ./mvnw -B -DskipTests org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -DoutputFormat=all -Dcyclonedx.skipAttach=true
@@ -59,10 +62,18 @@ jobs:
java-version: "25"
cache: maven
+ - name: Verify Maven Wrapper integrity (Unix)
+ if: runner.os != 'Windows'
+ run: java .github/scripts/VerifyMavenWrapperIntegrity.java
+
- name: Generate CycloneDX SBOM (aggregate)
if: runner.os != 'Windows'
run: ./mvnw -B -DskipTests org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -DoutputFormat=all -Dcyclonedx.skipAttach=true
+ - name: Verify Maven Wrapper integrity (Windows)
+ if: runner.os == 'Windows'
+ run: java .github/scripts/VerifyMavenWrapperIntegrity.java
+
- name: Generate CycloneDX SBOM (aggregate, Windows)
if: runner.os == 'Windows'
run: .\\mvnw.cmd -B -DskipTests org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -DoutputFormat=all -Dcyclonedx.skipAttach=true
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
index c0bcafe9..222cba3b 100644
--- a/.mvn/wrapper/maven-wrapper.properties
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -1,3 +1,4 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+distributionSha256Sum=0d7125e8c91097b36edb990ea5934e6c68b4440eef4ea96510a0f6815e7eeadb
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/MavenWrapperIntegrityTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MavenWrapperIntegrityTest.java
new file mode 100644
index 00000000..a96dba72
--- /dev/null
+++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MavenWrapperIntegrityTest.java
@@ -0,0 +1,183 @@
+package com.xtrmetl.etl.documentation;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Prevents Maven Wrapper bootstrap from executing an unverified Maven distribution.
+ *
+ * The wrapper downloads Maven before project compilation and tests can run. This repository
+ * therefore treats the Maven distribution URL and its reviewed SHA-256 as one atomic build-input
+ * contract. A Maven version or URL change must carry a newly reviewed checksum in the same change;
+ * deleting the checksum must fail deterministically without network access.
+ */
+class MavenWrapperIntegrityTest {
+
+ private static final String REVIEWED_DISTRIBUTION_URL =
+ "https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/"
+ + "apache-maven-3.9.11-bin.zip";
+ private static final String REVIEWED_DISTRIBUTION_SHA256 =
+ "0d7125e8c91097b36edb990ea5934e6c68b4440eef4ea96510a0f6815e7eeadb";
+ private static final String PREFLIGHT_COMMAND =
+ "run: java .github/scripts/VerifyMavenWrapperIntegrity.java";
+
+ /**
+ * Requires the fixed Maven 3.9.11 download to remain bound to its reviewed SHA-256 checksum.
+ *
+ * @throws IOException when the wrapper properties cannot be read as repository source
+ */
+ @Test
+ void bindsMavenDistributionUrlToReviewedSha256() throws IOException {
+ Properties properties = new Properties();
+ Path wrapperProperties = projectRoot().resolve(
+ ".mvn/wrapper/maven-wrapper.properties"
+ );
+ assertTrue(Files.isRegularFile(wrapperProperties), "Maven Wrapper properties must exist");
+
+ try (Reader reader = Files.newBufferedReader(wrapperProperties, StandardCharsets.UTF_8)) {
+ properties.load(reader);
+ }
+
+ assertEquals("3.3.4", properties.getProperty("wrapperVersion"));
+ assertEquals("only-script", properties.getProperty("distributionType"));
+ assertEquals(
+ REVIEWED_DISTRIBUTION_URL,
+ properties.getProperty("distributionUrl"),
+ "Changing the Maven distribution requires review of a matching checksum"
+ );
+
+ String distributionSha256 = properties.getProperty("distributionSha256Sum");
+ assertNotNull(
+ distributionSha256,
+ "Maven Wrapper must verify the downloaded Maven distribution with SHA-256"
+ );
+ assertTrue(
+ distributionSha256.matches("[0-9a-f]{64}"),
+ "distributionSha256Sum must be 64 lowercase hexadecimal characters"
+ );
+ assertEquals(
+ REVIEWED_DISTRIBUTION_SHA256,
+ distributionSha256,
+ "The checksum must match the reviewed Maven 3.9.11 distribution"
+ );
+ }
+
+ /**
+ * Requires a fail-closed integrity preflight immediately before every CI/SBOM wrapper step.
+ *
+ * The preflight must have the same GitHub Actions {@code if:} condition as the wrapper step,
+ * so neither Unix nor Windows execution can bootstrap Maven without validating the reviewed
+ * distribution URL and checksum first.
+ *
+ * @throws IOException when a workflow cannot be read as repository source
+ */
+ @Test
+ void preflightsEveryCiAndSbomWrapperInvocation() throws IOException {
+ for (String workflow : List.of(
+ ".github/workflows/ci.yml",
+ ".github/workflows/sbom.yml"
+ )) {
+ List lines = Files.readAllLines(
+ projectRoot().resolve(workflow),
+ StandardCharsets.UTF_8
+ );
+ int wrapperInvocations = 0;
+
+ for (int lineIndex = 0; lineIndex < lines.size(); lineIndex++) {
+ String line = lines.get(lineIndex).trim();
+ if (!line.startsWith("run:") || !containsWrapperInvocation(line)) {
+ continue;
+ }
+ wrapperInvocations++;
+
+ int wrapperStep = previousStepStart(lines, lineIndex);
+ int preflightStep = previousStepStart(lines, wrapperStep - 1);
+ assertTrue(
+ preflightStep >= 0,
+ workflow + ": wrapper invocation must have a preceding preflight step"
+ );
+
+ String preflightBlock = String.join(
+ "\n",
+ lines.subList(preflightStep, wrapperStep)
+ );
+ assertTrue(
+ preflightBlock.contains(PREFLIGHT_COMMAND),
+ workflow + ": every Maven Wrapper invocation must be immediately preceded "
+ + "by the integrity preflight"
+ );
+
+ String wrapperCondition = stepCondition(lines, wrapperStep, lineIndex + 1);
+ String preflightCondition = stepCondition(lines, preflightStep, wrapperStep);
+ assertEquals(
+ wrapperCondition,
+ preflightCondition,
+ workflow + ": preflight and wrapper step must use the same condition"
+ );
+ }
+
+ assertTrue(
+ wrapperInvocations > 0,
+ workflow + ": expected at least one Maven Wrapper invocation"
+ );
+ }
+ }
+
+ private static boolean containsWrapperInvocation(String line) {
+ return line.contains("./mvnw ") || line.contains(".\\mvnw.cmd ");
+ }
+
+ private static int previousStepStart(List lines, int fromIndex) {
+ for (int lineIndex = fromIndex; lineIndex >= 0; lineIndex--) {
+ if (lines.get(lineIndex).trim().startsWith("- name:")) {
+ return lineIndex;
+ }
+ }
+ return -1;
+ }
+
+ private static String stepCondition(List lines, int startInclusive, int endExclusive) {
+ for (int lineIndex = startInclusive; lineIndex < endExclusive; lineIndex++) {
+ String line = lines.get(lineIndex).trim();
+ if (line.startsWith("if:")) {
+ return line;
+ }
+ }
+ return "";
+ }
+
+ /**
+ * Finds the repository root from reactor-root or module-local Maven execution.
+ *
+ * @return absolute repository root containing the wrapper configuration
+ */
+ private static Path projectRoot() {
+ Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
+ Path lastPomParent = null;
+ while (current != null) {
+ if (Files.exists(current.resolve(".git"))) {
+ return current;
+ }
+ if (Files.exists(current.resolve("pom.xml"))) {
+ lastPomParent = current;
+ }
+ current = current.getParent();
+ }
+ if (lastPomParent != null) {
+ return lastPomParent;
+ }
+ throw new IllegalStateException("Could not find project root");
+ }
+}
diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/MavenWrapperWindowsInvocationMatcherTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MavenWrapperWindowsInvocationMatcherTest.java
new file mode 100644
index 00000000..be04ecee
--- /dev/null
+++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MavenWrapperWindowsInvocationMatcherTest.java
@@ -0,0 +1,26 @@
+package com.xtrmetl.etl.documentation;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies that the workflow matcher recognizes the Windows Maven Wrapper command form.
+ */
+class MavenWrapperWindowsInvocationMatcherTest {
+
+ @Test
+ void recognizesWindowsWrapperInvocation() throws ReflectiveOperationException {
+ Method matcher = MavenWrapperIntegrityTest.class.getDeclaredMethod(
+ "containsWrapperInvocation",
+ String.class
+ );
+ matcher.setAccessible(true);
+
+ boolean matched = (boolean) matcher.invoke(null, "run: .\\\\mvnw.cmd -B test");
+
+ assertTrue(matched, "Windows Maven Wrapper workflow command must be recognized");
+ }
+}