From b4b8c3d60602d1a7703995fd98832f712d8efe65 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 14:39:41 -0700 Subject: [PATCH 1/3] ci: attest Maven release artifact provenance --- .github/workflows/create-releases.yml | 41 +++++ SECURITY.md | 18 ++ .../gradle/GradleCacheTrustPolicyTest.kt | 164 +++++++++++++++++- 3 files changed, 221 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml index 31ee9bd34..d8d600cca 100644 --- a/.github/workflows/create-releases.yml +++ b/.github/workflows/create-releases.yml @@ -250,6 +250,8 @@ jobs: needs.runtime_compatibility.result == 'success' permissions: contents: read + id-token: write + attestations: write runs-on: ${{ vars.SDK_GHA_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 90 environment: publish @@ -316,6 +318,42 @@ jobs: exit 1 fi + - name: Build Maven artifacts for provenance + id: maven-artifacts + env: + RELEASE_TAG: ${{ needs.release.outputs.release_tag }} + run: | + set -euo pipefail + + version="${RELEASE_TAG#v}" + tasks=() + subjects=() + for artifact in $MAVEN_ARTIFACTS; do + tasks+=(":$artifact:jar") + subjects+=("$artifact/build/libs/$artifact-$version.jar") + done + + ./gradlew "${tasks[@]}" --no-configuration-cache + + for subject in "${subjects[@]}"; do + if [[ ! -f "$subject" || -L "$subject" ]]; then + echo "::error::Missing or unsafe Maven artifact: $subject" + exit 1 + fi + done + + sha256sum "${subjects[@]}" > "$RUNNER_TEMP/maven-artifact-provenance.sha256" + { + echo 'subject_paths<> "$GITHUB_OUTPUT" + + - name: Attest Maven artifact provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.maven-artifacts.outputs.subject_paths }} + - name: Publish to Maven Central env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.OPENAI_SONATYPE_USERNAME }} @@ -353,6 +391,9 @@ jobs: --stacktrace \ --no-configuration-cache + - name: Verify attested Maven artifacts + run: sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" + - name: Wait for Maven Central env: RELEASE_TAG: ${{ needs.release.outputs.release_tag }} diff --git a/SECURITY.md b/SECURITY.md index 6becb0f39..4ec9dbaa4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,6 +35,24 @@ Please give the maintainers a reasonable opportunity to investigate and address Thank you for helping us keep this SDK and the systems it interacts with secure. +## Maven Artifact Provenance + +Official Maven Central releases include GitHub artifact attestations for the +`openai-java`, `openai-java-core`, `openai-java-client-okhttp`, and +`openai-java-bedrock` binary JARs. Each attestation binds an artifact's digest to +the trusted release workflow, source repository, and commit using short-lived +GitHub OpenID Connect credentials. + +After downloading a published JAR from Maven Central, verify its provenance with: + +```sh +gh attestation verify path/to/openai-java-VERSION.jar -R openai/openai-java +``` + +Maven Central separately requires a publisher token and PGP signatures. Those +credentials remain restricted to the `main`-only publishing environment and are +not exposed to the attestation action. + ## Gradle Build Cache Trust The Kotlin Gradle plugin used to build this SDK is affected by diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt index 8d9264115..b3e3726e8 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt @@ -227,6 +227,87 @@ class GradleCacheTrustPolicyTest { assertPublishingCachePolicy(workflow) } + @Test + fun `publishing attests every released artifact before exposing signing secrets`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + assertPublishingProvenancePolicy(workflow) + } + + @Test + fun `publishing rejects missing or excessive attestation permissions`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + val permissions = + " permissions:\n" + + " contents: read\n" + + " id-token: write\n" + + " attestations: write\n" + + listOf( + permissions.replace(" id-token: write\n", ""), + permissions.replace(" attestations: write\n", ""), + permissions.replace(" contents: read\n", " contents: write\n"), + permissions + " actions: write\n", + ) + .forEach { unsafePermissions -> + val poisonedWorkflow = workflow.replaceFirst(permissions, unsafePermissions) + + assertTrue(poisonedWorkflow != workflow) + assertFailsWith { + assertPublishingProvenancePolicy(poisonedWorkflow) + } + } + } + + @Test + fun `publishing rejects unreviewed provenance subjects action inputs and revisions`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + val subject = " subject-path: \${{ steps.maven-artifacts.outputs.subject_paths }}" + + listOf( + workflow.replaceFirst(subject, " subject-path: '**/*.jar'"), + workflow.replaceFirst( + subject, + "$subject\n github-token: \${{ secrets.GITHUB_TOKEN }}", + ), + workflow.replaceFirst( + "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6", + "actions/attest@v4", + ), + ) + .forEach { poisonedWorkflow -> + assertTrue(poisonedWorkflow != workflow) + assertFailsWith { assertPublishingCachePolicy(poisonedWorkflow) } + } + } + + @Test + fun `publishing rejects provenance generation after signing secrets are exposed`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + val attestationStart = workflow.indexOf(" - name: Attest Maven artifact provenance\n") + val signingStart = workflow.indexOf(" - name: Publish to Maven Central\n") + val attestation = workflow.substring(attestationStart, signingStart) + val verification = " - name: Verify attested Maven artifacts\n" + val poisonedWorkflow = + workflow + .removeRange(attestationStart, signingStart) + .replaceFirst(verification, "$attestation$verification") + + assertTrue(poisonedWorkflow != workflow) + assertFailsWith { assertPublishingProvenancePolicy(poisonedWorkflow) } + } + + @Test + fun `published Maven artifact provenance verification is documented`() { + val securityPolicy = Path.of("../SECURITY.md").readText() + + assertContains(securityPolicy, "## Maven Artifact Provenance") + assertContains( + securityPolicy, + "gh attestation verify path/to/openai-java-VERSION.jar -R openai/openai-java", + ) + assertContains(securityPolicy, "not exposed to the attestation action") + } + @Test fun `publishing rejects untrusted pull request and completion triggers`() { val workflow = Path.of("../.github/workflows/create-releases.yml").readText() @@ -480,6 +561,7 @@ class GradleCacheTrustPolicyTest { "actions/setup-java" to setOf("distribution", "java-version"), "gradle/actions/setup-gradle" to setOf("cache-disabled"), "graalvm/setup-graalvm" to setOf("distribution", "java-version"), + "actions/attest" to setOf("subject-path"), ) publishJob.actions.forEach { action -> @@ -544,6 +626,73 @@ class GradleCacheTrustPolicyTest { }, "Privileged publishing must reject every cross-run artifact and GitHub cache action.", ) + + assertPublishingProvenancePolicy(workflow) + } + + private fun assertPublishingProvenancePolicy(workflow: String) { + val parsedWorkflow = parseWorkflow(workflow) + val publishJob = parsedWorkflow.job("publish") + + assertEquals( + mapOf("contents" to "read", "id-token" to "write", "attestations" to "write"), + publishJob.permissions, + "Publishing must have only the GitHub permissions required to attest release artifacts.", + ) + assertEquals( + listOf( + "openai-java", + "openai-java-core", + "openai-java-client-okhttp", + "openai-java-bedrock", + ), + parsedWorkflow.environment.getValue("MAVEN_ARTIFACTS").split(" "), + "Every published Maven artifact must receive a provenance attestation.", + ) + + val preparation = publishJob.steps.single { it.id == "maven-artifacts" } + val preparationIndex = publishJob.steps.indexOf(preparation) + val preparationScript = requireNotNull(preparation.run) + val attestation = publishJob.steps.single { it.action?.repository == "actions/attest" } + val attestationIndex = publishJob.steps.indexOf(attestation) + val signingIndex = publishJob.steps.indexOfFirst { "GPG_SIGNING_KEY" in it.environment } + val verificationIndex = + publishJob.steps.indexOfFirst { it.name == "Verify attested Maven artifacts" } + + assertEquals( + "\${{ needs.release.outputs.release_tag }}", + preparation.environment.getValue("RELEASE_TAG"), + ) + assertContains(preparationScript, "for artifact in \$MAVEN_ARTIFACTS; do") + assertContains(preparationScript, "tasks+=(\":\$artifact:jar\")") + assertContains( + preparationScript, + "subjects+=(\"\$artifact/build/libs/\$artifact-\$version.jar\")", + ) + assertContains(preparationScript, "./gradlew \"\${tasks[@]}\" --no-configuration-cache") + assertContains(preparationScript, "[[ ! -f \"\$subject\" || -L \"\$subject\" ]]") + assertContains( + preparationScript, + "sha256sum \"\${subjects[@]}\" > \"\$RUNNER_TEMP/maven-artifact-provenance.sha256\"", + ) + assertEquals( + mapOf("subject-path" to "\${{ steps.maven-artifacts.outputs.subject_paths }}"), + requireNotNull(attestation.action).inputs, + ) + assertTrue( + attestation.environment.isEmpty(), + "The provenance action must not receive Maven publishing or PGP credentials.", + ) + assertTrue(preparationIndex < attestationIndex, "Build Maven artifacts before attesting.") + assertTrue(signingIndex > attestationIndex, "Attest artifacts before exposing PGP secrets.") + assertTrue( + verificationIndex > signingIndex, + "Verify attested artifact digests after the publishing Gradle invocation.", + ) + assertEquals( + "sha256sum --check \"\$RUNNER_TEMP/maven-artifact-provenance.sha256\"", + publishJob.steps[verificationIndex].run, + ) } private fun artifactRestoreActions(workflow: String): List = @@ -577,6 +726,7 @@ class GradleCacheTrustPolicyTest { return Workflow( events, + document["env"].workflowScalars("workflow environment"), jobs.entries.associate { (name, value) -> val jobName = name.workflowScalar("job name") val job = value.workflowMapping("job $jobName") @@ -602,7 +752,12 @@ class GradleCacheTrustPolicyTest { } ?: emptyList() jobName to - WorkflowJob(jobName, job["outputs"].workflowScalars("job outputs"), steps) + WorkflowJob( + jobName, + job["outputs"].workflowScalars("job outputs"), + job["permissions"].workflowScalars("job permissions"), + steps, + ) }, ) } @@ -642,7 +797,11 @@ class GradleCacheTrustPolicyTest { else -> error("$description must be a YAML scalar.") } - private data class Workflow(val events: Set, val jobs: Map) { + private data class Workflow( + val events: Set, + val environment: Map, + val jobs: Map, + ) { fun job(name: String): WorkflowJob = jobs[name] ?: error("GitHub Actions workflow is missing job $name.") } @@ -650,6 +809,7 @@ class GradleCacheTrustPolicyTest { private data class WorkflowJob( val name: String, val outputs: Map, + val permissions: Map, val steps: List, ) { val actions: List From 49efc88371d34928285007f94c3b3feaee340706 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 22:12:07 +0000 Subject: [PATCH 2/3] fix: verify immutable attested jars before Maven publication --- .github/workflows/create-releases.yml | 7 + .../gradle/GradleCacheTrustPolicyTest.kt | 174 ++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml index d8d600cca..46c3b541d 100644 --- a/.github/workflows/create-releases.yml +++ b/.github/workflows/create-releases.yml @@ -387,7 +387,14 @@ jobs: fi export GPG_SIGNING_KEY_ID + publish_exclusions=() + for artifact in $MAVEN_ARTIFACTS; do + publish_exclusions+=("--exclude-task" ":$artifact:jar") + done + + sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" ./gradlew publishAndReleaseToMavenCentral \ + "${publish_exclusions[@]}" \ --stacktrace \ --no-configuration-cache diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt index b3e3726e8..e6a7a4b97 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt @@ -233,6 +233,144 @@ class GradleCacheTrustPolicyTest { assertPublishingProvenancePolicy(workflow) } + @Test + fun `publishing rejects missing or late prepublication digest verification`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + val verification = + " sha256sum --check \"\$RUNNER_TEMP/maven-artifact-provenance.sha256\"\n" + + listOf( + workflow.replaceFirst(verification, ""), + workflow.replaceFirst( + verification, + " ./gradlew publishAndReleaseToMavenCentral\n$verification", + ), + ) + .forEach { poisonedWorkflow -> + assertTrue(poisonedWorkflow != workflow) + assertFailsWith { + assertPublishingProvenancePolicy(poisonedWorkflow) + } + } + } + + @Test + fun `publishing rejects missing or misdirected attested jar producer exclusions`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + val producer = " publish_exclusions+=(\"--exclude-task\" \":\$artifact:jar\")" + val invocation = " \"\${publish_exclusions[@]}\"" + + listOf( + workflow.replaceFirst(producer, ""), + workflow.replaceFirst(producer, producer.replace(":jar", ":sourcesJar")), + workflow.replaceFirst(producer, producer.replace("\$artifact", "openai-java")), + workflow.replaceFirst(invocation, " --no-daemon"), + ) + .forEach { poisonedWorkflow -> + assertTrue(poisonedWorkflow != workflow) + assertFailsWith { + assertPublishingProvenancePolicy(poisonedWorkflow) + } + } + } + + @Test + fun `tampered provenance subjects abort before irreversible Maven publication`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + val parsedWorkflow = parseWorkflow(workflow) + val artifacts = parsedWorkflow.environment.getValue("MAVEN_ARTIFACTS").split(" ") + val publishScript = + requireNotNull( + parsedWorkflow + .job("publish") + .steps + .single { it.name == "Publish to Maven Central" } + .run + ) + val guardedPublication = publishScript.substringAfter("export GPG_SIGNING_KEY_ID\n") + val runnerTemp = temporaryDirectory.resolve("runner").also(Files::createDirectories) + val subjects = + artifacts.map { artifact -> + temporaryDirectory.resolve("$artifact/build/libs/$artifact-test.jar").apply { + Files.createDirectories(parent) + writeText("attested-$artifact") + } + } + val manifest = runnerTemp.resolve("maven-artifact-provenance.sha256") + val digest = + ProcessBuilder( + listOf("sha256sum") + + subjects.map { temporaryDirectory.relativize(it).toString() } + ) + .directory(temporaryDirectory.toFile()) + .redirectOutput(manifest.toFile()) + .start() + assertEquals(0, digest.waitFor()) + + val wrapper = temporaryDirectory.resolve("gradlew") + wrapper.writeText( + listOf( + "#!/usr/bin/env bash", + "set -euo pipefail", + "touch \"\$RUNNER_TEMP/publication-started\"", + "printf '%s\\n' \"\$@\" > \"\$RUNNER_TEMP/publication-arguments\"", + "exclusions=' '", + "while [[ \$# -gt 0 ]]; do", + " if [[ \$1 == --exclude-task ]]; then", + " exclusions=\"\$exclusions\$2 \"", + " shift 2", + " else", + " shift", + " fi", + "done", + "for artifact in \$MAVEN_ARTIFACTS; do", + " if [[ \"\$exclusions\" != *\" :\$artifact:jar \"* ]]; then", + " printf 'regenerated' > \"\$artifact/build/libs/\$artifact-test.jar\"", + " fi", + "done", + ) + .joinToString("\n", postfix = "\n") + ) + assertTrue(wrapper.toFile().setExecutable(true, true)) + + fun publish(): Pair { + val process = + ProcessBuilder("bash", "-euo", "pipefail", "-c", guardedPublication) + .directory(temporaryDirectory.toFile()) + .redirectErrorStream(true) + .apply { + environment()["RUNNER_TEMP"] = runnerTemp.toString() + environment()["MAVEN_ARTIFACTS"] = artifacts.joinToString(" ") + } + .start() + val output = process.inputStream.bufferedReader().readText() + return process.waitFor() to output + } + + val altered = subjects[1] + val original = altered.readText() + altered.writeText("tampered-after-attestation") + val rejected = publish() + assertTrue(rejected.first != 0, rejected.second) + assertFalse( + Files.exists(runnerTemp.resolve("publication-started")), + "Tampered artifacts must never reach the irreversible publication invocation.", + ) + + altered.writeText(original) + val accepted = publish() + assertEquals(0, accepted.first, accepted.second) + assertTrue(Files.exists(runnerTemp.resolve("publication-started"))) + subjects.forEachIndexed { index, subject -> + assertEquals("attested-" + artifacts[index], subject.readText()) + } + val arguments = runnerTemp.resolve("publication-arguments").readText().lines() + artifacts.forEach { artifact -> + val index = arguments.indexOf(":$artifact:jar") + assertTrue(index > 0 && arguments[index - 1] == "--exclude-task") + } + } + @Test fun `publishing rejects missing or excessive attestation permissions`() { val workflow = Path.of("../.github/workflows/create-releases.yml").readText() @@ -685,6 +823,42 @@ class GradleCacheTrustPolicyTest { ) assertTrue(preparationIndex < attestationIndex, "Build Maven artifacts before attesting.") assertTrue(signingIndex > attestationIndex, "Attest artifacts before exposing PGP secrets.") + val publicationScript = requireNotNull(publishJob.steps[signingIndex].run) + val verification = "sha256sum --check \"\$RUNNER_TEMP/maven-artifact-provenance.sha256\"" + val verificationStart = publicationScript.indexOf(verification) + val publicationStart = + publicationScript.indexOf("./gradlew publishAndReleaseToMavenCentral") + assertTrue( + verificationStart >= 0 && publicationStart > verificationStart, + "Verify attested artifact digests before irreversible Maven Central publication.", + ) + assertTrue( + publicationScript + .substring(verificationStart + verification.length, publicationStart) + .isBlank(), + "Verify attested artifact digests immediately before the publishing invocation.", + ) + + val exclusionsStart = publicationScript.indexOf("publish_exclusions=()") + val artifactLoop = + publicationScript.indexOf("for artifact in \$MAVEN_ARTIFACTS; do", exclusionsStart) + val producerExclusion = + publicationScript.indexOf( + "publish_exclusions+=(\"--exclude-task\" \":\$artifact:jar\")", + artifactLoop, + ) + assertTrue( + exclusionsStart >= 0 && + artifactLoop > exclusionsStart && + producerExclusion > artifactLoop && + verificationStart > producerExclusion, + "Exclude the exact JAR producer for every attested Maven artifact before publishing.", + ) + assertTrue( + publicationScript.substring(publicationStart).contains("\"\${publish_exclusions[@]}\""), + "The publishing invocation must receive every attested JAR producer exclusion.", + ) + assertTrue( verificationIndex > signingIndex, "Verify attested artifact digests after the publishing Gradle invocation.", From 83203039841e59c5ddae5c06ebfeb6b92dfdf5d1 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 22:24:44 +0000 Subject: [PATCH 3/3] fix: bind release provenance to the verified source commit --- .github/workflows/create-releases.yml | 61 ++++++ .../gradle/GradleCacheTrustPolicyTest.kt | 181 +++++++++++++++++- 2 files changed, 240 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml index 46c3b541d..7da4c9f25 100644 --- a/.github/workflows/create-releases.yml +++ b/.github/workflows/create-releases.yml @@ -322,9 +322,20 @@ jobs: id: maven-artifacts env: RELEASE_TAG: ${{ needs.release.outputs.release_tag }} + SOURCE_SHA: ${{ needs.release.outputs.source_sha }} run: | set -euo pipefail + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ || + "$SOURCE_SHA" != "$(git rev-parse HEAD)" ]]; then + echo "::error::The checked-out source does not match the verified release SHA" + exit 1 + fi + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid verified release tag" + exit 1 + fi + version="${RELEASE_TAG#v}" tasks=() subjects=() @@ -343,16 +354,66 @@ jobs: done sha256sum "${subjects[@]}" > "$RUNNER_TEMP/maven-artifact-provenance.sha256" + + workflow_path="${GITHUB_WORKFLOW_REF#"$GITHUB_REPOSITORY/"}" + workflow_path="${workflow_path%@*}" + jq -n \ + --arg repository "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ + --arg workflow_ref "$GITHUB_REF" \ + --arg workflow_path "$workflow_path" \ + --arg workflow_identity "$GITHUB_SERVER_URL/$GITHUB_WORKFLOW_REF" \ + --arg event_name "$GITHUB_EVENT_NAME" \ + --arg repository_id "$GITHUB_REPOSITORY_ID" \ + --arg repository_owner_id "$GITHUB_REPOSITORY_OWNER_ID" \ + --arg runner_environment "$RUNNER_ENVIRONMENT" \ + --arg source_sha "$SOURCE_SHA" \ + --arg release_tag "$RELEASE_TAG" \ + --arg invocation_id \ + "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT" \ + '{ + buildDefinition: { + buildType: "https://actions.github.io/buildtypes/workflow/v1", + externalParameters: { + workflow: { + ref: $workflow_ref, + repository: $repository, + path: $workflow_path + } + }, + internalParameters: { + github: { + event_name: $event_name, + repository_id: $repository_id, + repository_owner_id: $repository_owner_id, + runner_environment: $runner_environment + } + }, + resolvedDependencies: [ + { + uri: ("git+" + $repository + "@refs/tags/" + $release_tag), + digest: { gitCommit: $source_sha } + } + ] + }, + runDetails: { + builder: { id: $workflow_identity }, + metadata: { invocationId: $invocation_id } + } + }' > "$RUNNER_TEMP/maven-release-provenance.json" + { echo 'subject_paths<> "$GITHUB_OUTPUT" - name: Attest Maven artifact provenance uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-path: ${{ steps.maven-artifacts.outputs.subject_paths }} + predicate-type: https://slsa.dev/provenance/v1 + predicate-path: ${{ steps.maven-artifacts.outputs.predicate_path }} - name: Publish to Maven Central env: diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt index e6a7a4b97..68795f21e 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt @@ -371,6 +371,162 @@ class GradleCacheTrustPolicyTest { } } + @Test + fun `retry provenance resolves the checked out historical tag not the newer workflow SHA`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + val parsedWorkflow = parseWorkflow(workflow) + val preparation = + requireNotNull( + parsedWorkflow.job("publish").steps.single { it.id == "maven-artifacts" }.run + ) + val artifacts = parsedWorkflow.environment.getValue("MAVEN_ARTIFACTS").split(" ") + val checkout = temporaryDirectory.resolve("checkout").also(Files::createDirectories) + + fun git(vararg arguments: String): String { + val process = + ProcessBuilder(listOf("git", "-C", checkout.toString()) + arguments) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + assertEquals(0, process.waitFor(), output) + return output.trim() + } + + git("init", "--quiet", "--initial-branch=main") + git("config", "user.name", "Release provenance fixture") + git("config", "user.email", "release-fixture@example.invalid") + val tracked = checkout.resolve("tracked.txt") + tracked.writeText("historical release") + git("add", "tracked.txt") + git("commit", "--quiet", "-m", "historical release") + val sourceSha = git("rev-parse", "HEAD") + git("tag", "v1.2.3") + tracked.writeText("newer main") + git("add", "tracked.txt") + git("commit", "--quiet", "-m", "advance main") + val workflowSha = git("rev-parse", "HEAD") + assertTrue(sourceSha != workflowSha) + git("checkout", "--quiet", sourceSha) + + val runnerTemp = temporaryDirectory.resolve("runner").also(Files::createDirectories) + val wrapper = checkout.resolve("gradlew") + wrapper.writeText( + listOf( + "#!/usr/bin/env bash", + "set -euo pipefail", + "touch \"\$RUNNER_TEMP/build-started\"", + "for task in \"\$@\"; do", + " if [[ \"\$task\" == :*:jar ]]; then", + " artifact=\"\${task#:}\"", + " artifact=\"\${artifact%:jar}\"", + " mkdir -p \"\$artifact/build/libs\"", + " printf 'attested-%s' \"\$artifact\" > \"\$artifact/build/libs/\$artifact-1.2.3.jar\"", + " fi", + "done", + ) + .joinToString("\n", postfix = "\n") + ) + assertTrue(wrapper.toFile().setExecutable(true, true)) + + fun prepare(source: String, tag: String): Pair { + val process = + ProcessBuilder("bash", "-euo", "pipefail", "-c", preparation) + .directory(checkout.toFile()) + .redirectErrorStream(true) + .apply { + val values = environment() + values["MAVEN_ARTIFACTS"] = artifacts.joinToString(" ") + values["RELEASE_TAG"] = tag + values["SOURCE_SHA"] = source + values["RUNNER_TEMP"] = runnerTemp.toString() + values["GITHUB_OUTPUT"] = runnerTemp.resolve("step-output").toString() + values["GITHUB_SERVER_URL"] = "https://github.com" + values["GITHUB_REPOSITORY"] = "openai/openai-java" + values["GITHUB_REPOSITORY_ID"] = "100" + values["GITHUB_REPOSITORY_OWNER_ID"] = "200" + values["GITHUB_REF"] = "refs/heads/main" + values["GITHUB_SHA"] = workflowSha + values["GITHUB_WORKFLOW_REF"] = + "openai/openai-java/.github/workflows/create-releases.yml@refs/heads/main" + values["GITHUB_EVENT_NAME"] = "workflow_dispatch" + values["GITHUB_RUN_ID"] = "12345" + values["GITHUB_RUN_ATTEMPT"] = "2" + values["RUNNER_ENVIRONMENT"] = "github-hosted" + } + .start() + val output = process.inputStream.bufferedReader().readText() + return process.waitFor() to output + } + + val accepted = prepare(sourceSha, "v1.2.3") + assertEquals(0, accepted.first, accepted.second) + val predicate = + Yaml(SafeConstructor(LoaderOptions())) + .load>(runnerTemp.resolve("maven-release-provenance.json").readText()) + val definition = predicate["buildDefinition"] as Map<*, *> + assertEquals("https://actions.github.io/buildtypes/workflow/v1", definition["buildType"]) + val external = definition["externalParameters"] as Map<*, *> + val workflowSource = external["workflow"] as Map<*, *> + assertEquals("refs/heads/main", workflowSource["ref"]) + assertEquals(".github/workflows/create-releases.yml", workflowSource["path"]) + val dependency = (definition["resolvedDependencies"] as List<*>).single() as Map<*, *> + assertEquals( + "git+https://github.com/openai/openai-java@refs/tags/v1.2.3", + dependency["uri"], + ) + val digest = dependency["digest"] as Map<*, *> + assertEquals(sourceSha, digest["gitCommit"]) + assertTrue(digest["gitCommit"] != workflowSha) + val details = predicate["runDetails"] as Map<*, *> + val builder = details["builder"] as Map<*, *> + assertEquals( + "https://github.com/openai/openai-java/.github/workflows/create-releases.yml@refs/heads/main", + builder["id"], + ) + + listOf( + workflowSha to "v1.2.3", + "invalid-source" to "v1.2.3", + sourceSha to "v1.2.3;touch-injected", + ) + .forEach { (source, tag) -> + Files.deleteIfExists(runnerTemp.resolve("build-started")) + val rejected = prepare(source, tag) + assertTrue(rejected.first != 0, rejected.second) + assertFalse( + Files.exists(runnerTemp.resolve("build-started")), + "Invalid source or tag must fail before artifact creation.", + ) + } + } + + @Test + fun `publishing rejects run-SHA provenance and unreviewed custom predicate inputs`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + val source = " SOURCE_SHA: \${{ needs.release.outputs.source_sha }}" + val argument = " --arg source_sha \"\$SOURCE_SHA\"" + val checkout = "\"\$SOURCE_SHA\" != \"\$(git rev-parse HEAD)\"" + val predicateType = " predicate-type: https://slsa.dev/provenance/v1" + val predicatePath = + " predicate-path: \${{ steps.maven-artifacts.outputs.predicate_path }}" + + listOf( + workflow.replaceFirst(source, " SOURCE_SHA: \${{ github.sha }}"), + workflow.replaceFirst(argument, argument.replace("\$SOURCE_SHA", "\$GITHUB_SHA")), + workflow.replaceFirst(checkout, checkout.replace("HEAD", "refs/heads/main")), + workflow.replaceFirst( + predicateType, + predicateType.replace("slsa.dev", "example.invalid"), + ), + workflow.replaceFirst(predicatePath, ""), + workflow.replaceFirst(predicatePath, "$predicatePath\n predicate: '{}'"), + ) + .forEach { poisonedWorkflow -> + assertTrue(poisonedWorkflow != workflow) + assertFailsWith { assertPublishingCachePolicy(poisonedWorkflow) } + } + } + @Test fun `publishing rejects missing or excessive attestation permissions`() { val workflow = Path.of("../.github/workflows/create-releases.yml").readText() @@ -699,7 +855,7 @@ class GradleCacheTrustPolicyTest { "actions/setup-java" to setOf("distribution", "java-version"), "gradle/actions/setup-gradle" to setOf("cache-disabled"), "graalvm/setup-graalvm" to setOf("distribution", "java-version"), - "actions/attest" to setOf("subject-path"), + "actions/attest" to setOf("subject-path", "predicate-type", "predicate-path"), ) publishJob.actions.forEach { action -> @@ -801,6 +957,23 @@ class GradleCacheTrustPolicyTest { "\${{ needs.release.outputs.release_tag }}", preparation.environment.getValue("RELEASE_TAG"), ) + assertEquals( + "\${{ needs.release.outputs.source_sha }}", + preparation.environment.getValue("SOURCE_SHA"), + ) + assertContains(preparationScript, "\"\$SOURCE_SHA\" != \"\$(git rev-parse HEAD)\"") + assertContains(preparationScript, "--arg source_sha \"\$SOURCE_SHA\"") + assertContains(preparationScript, "--arg workflow_ref \"\$GITHUB_REF\"") + assertContains( + preparationScript, + "uri: (\"git+\" + \$repository + \"@refs/tags/\" + \$release_tag)", + ) + assertContains(preparationScript, "digest: { gitCommit: \$source_sha }") + assertContains(preparationScript, "builder: { id: \$workflow_identity }") + assertContains( + preparationScript, + "predicate_path=\$RUNNER_TEMP/maven-release-provenance.json", + ) assertContains(preparationScript, "for artifact in \$MAVEN_ARTIFACTS; do") assertContains(preparationScript, "tasks+=(\":\$artifact:jar\")") assertContains( @@ -814,7 +987,11 @@ class GradleCacheTrustPolicyTest { "sha256sum \"\${subjects[@]}\" > \"\$RUNNER_TEMP/maven-artifact-provenance.sha256\"", ) assertEquals( - mapOf("subject-path" to "\${{ steps.maven-artifacts.outputs.subject_paths }}"), + mapOf( + "subject-path" to "\${{ steps.maven-artifacts.outputs.subject_paths }}", + "predicate-type" to "https://slsa.dev/provenance/v1", + "predicate-path" to "\${{ steps.maven-artifacts.outputs.predicate_path }}", + ), requireNotNull(attestation.action).inputs, ) assertTrue(