diff --git a/.github/scripts/VerifyMavenWrapperIntegrity.java b/.github/scripts/VerifyMavenWrapperIntegrity.java new file mode 100644 index 00000000..bef0bd55 --- /dev/null +++ b/.github/scripts/VerifyMavenWrapperIntegrity.java @@ -0,0 +1,62 @@ +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.Properties; + +/** + * Fail-closed Maven Wrapper bootstrap preflight for GitHub Actions. + * + *

This source-file-mode utility runs on the JDK configured by the workflow before any + * {@code mvnw} or {@code mvnw.cmd} invocation. It verifies that the Maven distribution remains + * bound to the exact reviewed URL and SHA-256 checksum, so deleting or changing the checksum + * cannot silently bypass integrity verification before Maven starts.

+ */ +public final class VerifyMavenWrapperIntegrity { + + private static final String REVIEWED_WRAPPER_VERSION = "3.3.4"; + private static final String REVIEWED_DISTRIBUTION_TYPE = "only-script"; + 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 VerifyMavenWrapperIntegrity() { + } + + /** + * Validates the reviewed Maven Wrapper bootstrap inputs before Maven can execute. + * + * @param args ignored command-line arguments + * @throws IOException when the wrapper properties cannot be read + */ + public static void main(String[] args) throws IOException { + Path propertiesPath = Path.of(".mvn", "wrapper", "maven-wrapper.properties"); + if (!Files.isRegularFile(propertiesPath)) { + throw new IllegalStateException("Missing Maven Wrapper properties: " + propertiesPath); + } + + Properties properties = new Properties(); + try (Reader reader = Files.newBufferedReader(propertiesPath, StandardCharsets.UTF_8)) { + properties.load(reader); + } + + requireExact(properties, "wrapperVersion", REVIEWED_WRAPPER_VERSION); + requireExact(properties, "distributionType", REVIEWED_DISTRIBUTION_TYPE); + requireExact(properties, "distributionUrl", REVIEWED_DISTRIBUTION_URL); + requireExact(properties, "distributionSha256Sum", REVIEWED_DISTRIBUTION_SHA256); + System.out.println("Maven Wrapper integrity preflight passed."); + } + + private static void requireExact(Properties properties, String key, String expected) { + String actual = properties.getProperty(key); + if (!expected.equals(actual)) { + throw new IllegalStateException( + "Maven Wrapper integrity preflight rejected " + key + + ": expected reviewed value but found " + String.valueOf(actual) + ); + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9780487..f127b84f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,13 +38,21 @@ 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 + run: .\mvnw.cmd -B test test_self_hosted: if: ${{ github.event_name == 'workflow_dispatch' && inputs.use_self_hosted == true }} @@ -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 + run: .\mvnw.cmd -B test diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 26aefe40..20f92d63 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,13 +62,21 @@ 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 + run: .\mvnw.cmd -B -DskipTests org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -DoutputFormat=all -Dcyclonedx.skipAttach=true - name: Upload SBOM artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 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"); + } +}