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