From 718de25b2b95167bdab897e5463667b77030d354 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:40:57 +0100 Subject: [PATCH] Bind release activation to trusted provenance --- docs/CODE_SIGNING_POLICY.md | 11 ++- .../verify_github_release_integrity_test.py | 41 +++++++++-- .../verify_release_container_images_test.py | 14 ++++ scripts/require-safe-gh-attestation.sh | 42 +++++++++++ .../tests/test_require_safe_gh_attestation.py | 72 +++++++++++++++++++ scripts/verify-github-release-integrity.sh | 49 ++++++++++--- scripts/verify-release-container-images.sh | 4 ++ 7 files changed, 219 insertions(+), 14 deletions(-) create mode 100755 scripts/require-safe-gh-attestation.sh create mode 100644 scripts/tests/test_require_safe_gh_attestation.py diff --git a/docs/CODE_SIGNING_POLICY.md b/docs/CODE_SIGNING_POLICY.md index c5619347a..ca345563f 100644 --- a/docs/CODE_SIGNING_POLICY.md +++ b/docs/CODE_SIGNING_POLICY.md @@ -73,6 +73,10 @@ Normal stable publication and stable dry runs select `signpath` directly. policy is invalid. - Release checksums and detached signatures are published alongside artifacts and verified independently after publication. +- Release activation requires GitHub CLI 2.97.0 or newer, which includes the + literal signer-identity matcher fix. The published checksum manifest must + carry build provenance from the exact `create-release.yml` workflow and + release source commit; repository-level provenance is not sufficient. - Every new release is assembled and validated as a draft. Its activation marker is uploaded and digest-checked before publication; GitHub must then report the published release as immutable, protecting its tag and complete @@ -81,8 +85,11 @@ Normal stable publication and stable dry runs select `signpath` directly. environments are not promoted until `gh release verify --repo rcourtman/Pulse` validates GitHub's signed release attestation and `gh release verify-asset --repo rcourtman/Pulse` binds the - downloaded activation marker to that attestation. Operators can use the same - commands to verify the packet and any downloaded release asset independently. + downloaded activation marker to that attestation. The activation verifier + also binds the release's downloaded `checksums.txt` to that immutable packet + and verifies its exact workflow and source provenance. Operators can use the + same commands to verify the packet and any downloaded release asset + independently. ## Project roles diff --git a/scripts/release_control/verify_github_release_integrity_test.py b/scripts/release_control/verify_github_release_integrity_test.py index f7544e1b6..e8702e6d6 100644 --- a/scripts/release_control/verify_github_release_integrity_test.py +++ b/scripts/release_control/verify_github_release_integrity_test.py @@ -23,6 +23,8 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): *, verification_succeeds: bool = True, asset_verification_succeeds: bool = True, + provenance_verification_succeeds: bool = True, + gh_version: str = "2.97.0", ): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -33,6 +35,10 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): f"""\ #!/usr/bin/env bash set -euo pipefail + if [ "$1" = version ]; then + printf 'gh version %s (test)\\n' "$GH_VERSION" + exit 0 + fi printf '%s\\n' "$*" >> {calls!s} if [ "$1" = api ]; then cat <<'JSON' @@ -49,6 +55,7 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): if [ "$1" = --dir ]; then mkdir -p "$2" printf '%s\\n' '{{"schema_version": 1}}' > "$2/release-activation.json" + printf '%s\\n' 'abc pulse-v6.5.0-linux-amd64.tar.gz' > "$2/checksums.txt" exit 0 fi shift @@ -59,6 +66,9 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): printf '%s\\n' '{{"verified": true}}' exit {0 if asset_verification_succeeds else 1} fi + if [ "$1 $2" = "attestation verify" ]; then + exit {0 if provenance_verification_succeeds else 1} + fi exit 64 """ ), @@ -71,6 +81,7 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): "PATH": f"{root}:{env['PATH']}", "PULSE_RELEASE_ATTESTATION_ATTEMPTS": "1", "PULSE_RELEASE_ATTESTATION_RETRY_DELAY": "0", + "GH_VERSION": gh_version, } ) result = subprocess.run( @@ -107,9 +118,19 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): def test_accepts_immutable_release_with_verified_attestation(self) -> None: result, calls = self.run_verifier(self.release()) self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("is immutable, attested, and activation-asset-bound", result.stdout) + self.assertIn( + "is immutable, release-attested, activation-asset-bound, and build-provenance-bound", + result.stdout, + ) self.assertIn("release verify v6.5.0 --repo rcourtman/Pulse --format json", calls) self.assertIn("release verify-asset v6.5.0", calls) + self.assertIn("attestation verify ", calls) + self.assertIn( + "--signer-workflow github.com/rcourtman/Pulse/.github/workflows/create-release.yml", + calls, + ) + self.assertIn(f"--source-digest {SOURCE_SHA}", calls) + self.assertIn("--predicate-type https://slsa.dev/provenance/v1", calls) def test_rejects_mutable_release_before_attestation(self) -> None: result, calls = self.run_verifier(self.release(immutable=False)) @@ -125,9 +146,7 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): self.assertIn("activation marker", result.stderr) def test_rejects_failed_release_attestation(self) -> None: - result, _ = self.run_verifier( - self.release(), verification_succeeds=False - ) + result, _ = self.run_verifier(self.release(), verification_succeeds=False) self.assertNotEqual(result.returncode, 0) self.assertIn("attestation verification failed", result.stderr) @@ -139,6 +158,20 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): self.assertIn("activation asset verification failed", result.stderr) self.assertIn("release verify-asset v6.5.0", calls) + def test_rejects_checksum_manifest_without_build_provenance(self) -> None: + result, calls = self.run_verifier( + self.release(), provenance_verification_succeeds=False + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("checksum manifest build provenance verification failed", result.stderr) + self.assertIn("attestation verify", calls) + + def test_rejects_an_unsafe_github_cli_before_release_lookup(self) -> None: + result, calls = self.run_verifier(self.release(), gh_version="2.96.1") + self.assertNotEqual(result.returncode, 0) + self.assertIn("too old for release attestation policy enforcement", result.stderr) + self.assertEqual(calls, "") + if __name__ == "__main__": unittest.main() diff --git a/scripts/release_control/verify_release_container_images_test.py b/scripts/release_control/verify_release_container_images_test.py index 007937e95..3ac0b89a5 100644 --- a/scripts/release_control/verify_release_container_images_test.py +++ b/scripts/release_control/verify_release_container_images_test.py @@ -22,6 +22,7 @@ class VerifyReleaseContainerImagesTests(unittest.TestCase): *, overrides: dict[str, str] | None = None, gh_exit: int = 0, + gh_version: str = "2.97.0", tag: str = "v6.4.1", source_sha: str = SOURCE_SHA, ) -> tuple[subprocess.CompletedProcess[str], str]: @@ -63,6 +64,10 @@ class VerifyReleaseContainerImagesTests(unittest.TestCase): textwrap.dedent( """\ #!/bin/sh + if [ "$1" = version ]; then + printf 'gh version %s (test)\n' "$GH_VERSION" + exit 0 + fi printf '%s\n' "$*" >> "$GH_LOG" exit "$GH_EXIT" """ @@ -79,6 +84,7 @@ class VerifyReleaseContainerImagesTests(unittest.TestCase): "DIGEST_FILE": str(digest_file), "GH_LOG": str(gh_log), "GH_EXIT": str(gh_exit), + "GH_VERSION": gh_version, } ) result = subprocess.run( @@ -109,6 +115,7 @@ class VerifyReleaseContainerImagesTests(unittest.TestCase): calls, ) self.assertIn(f"--source-digest {SOURCE_SHA}", calls) + self.assertIn("--predicate-type https://slsa.dev/provenance/v1", calls) def test_rejects_a_moved_exact_version_tag_before_attestation(self) -> None: changed = "sha256:" + "c" * 64 @@ -126,6 +133,13 @@ class VerifyReleaseContainerImagesTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertEqual(len(calls.splitlines()), 1) + def test_rejects_an_unsafe_github_cli_before_registry_calls(self) -> None: + result, calls = self.run_verifier(gh_version="2.96.1") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("too old for release attestation policy enforcement", result.stderr) + self.assertEqual(calls, "") + def test_rejects_invalid_release_identity_without_registry_calls(self) -> None: result, calls = self.run_verifier(source_sha="main") diff --git a/scripts/require-safe-gh-attestation.sh b/scripts/require-safe-gh-attestation.sh new file mode 100755 index 000000000..fbb39c5fe --- /dev/null +++ b/scripts/require-safe-gh-attestation.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +# Refuse to make release decisions with GitHub CLI versions whose attestation +# identity matching is known to be unsafe. GitHub CLI 2.97.0 escaped repository +# and workflow names before constructing the certificate matcher; older +# versions can accept a lookalike signer for a literal --signer-workflow policy. + +set -euo pipefail + +readonly MINIMUM_GH_VERSION="2.97.0" + +if ! version_output="$(gh version 2>/dev/null | head -n 1)"; then + echo "Unable to run the GitHub CLI required for safe attestation verification." >&2 + exit 1 +fi +if ! [[ "$version_output" =~ ^gh\ version\ ([0-9]+\.[0-9]+\.[0-9]+) ]]; then + echo "Unable to determine the GitHub CLI version required for safe attestation verification." >&2 + exit 1 +fi +actual_version="${BASH_REMATCH[1]}" + +version_at_least() { + local actual="$1" + local required="$2" + local actual_major actual_minor actual_patch + local required_major required_minor required_patch + + IFS=. read -r actual_major actual_minor actual_patch <<<"$actual" + IFS=. read -r required_major required_minor required_patch <<<"$required" + + (( actual_major > required_major )) || + { (( actual_major == required_major )) && (( actual_minor > required_minor )); } || + { (( actual_major == required_major )) && (( actual_minor == required_minor )) && + (( actual_patch >= required_patch )); } +} + +if ! version_at_least "$actual_version" "$MINIMUM_GH_VERSION"; then + echo "GitHub CLI ${actual_version} is too old for release attestation policy enforcement; ${MINIMUM_GH_VERSION} or newer is required." >&2 + exit 1 +fi + +echo "[OK] GitHub CLI ${actual_version} satisfies the safe attestation verifier floor (${MINIMUM_GH_VERSION})." >&2 diff --git a/scripts/tests/test_require_safe_gh_attestation.py b/scripts/tests/test_require_safe_gh_attestation.py new file mode 100644 index 000000000..5625b1c30 --- /dev/null +++ b/scripts/tests/test_require_safe_gh_attestation.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "require-safe-gh-attestation.sh" + + +class RequireSafeGitHubAttestationTest(unittest.TestCase): + def run_check(self, version_line: str, *, exit_code: int = 0): + with tempfile.TemporaryDirectory() as directory: + fake_gh = Path(directory) / "gh" + fake_gh.write_text( + textwrap.dedent( + f"""\ + #!/bin/sh + printf '%s\\n' {version_line!r} + exit {exit_code} + """ + ), + encoding="utf-8", + ) + fake_gh.chmod(0o755) + env = os.environ.copy() + env.update( + { + "PATH": f"{directory}:{env['PATH']}", + } + ) + return subprocess.run( + [str(SCRIPT)], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + def test_accepts_floor_and_newer_major_version(self) -> None: + for version in ("2.97.0", "2.101.3", "3.0.0"): + with self.subTest(version=version): + result = self.run_check(f"gh version {version} (test)") + self.assertEqual(result.returncode, 0, result.stderr) + + def test_rejects_versions_before_safe_matcher_fix(self) -> None: + for version in ("2.96.9", "2.9.99", "1.120.0"): + with self.subTest(version=version): + result = self.run_check(f"gh version {version} (test)") + self.assertNotEqual(result.returncode, 0) + self.assertIn("too old", result.stderr) + + def test_rejects_unparseable_versions(self) -> None: + malformed = self.run_check("github cli current") + self.assertNotEqual(malformed.returncode, 0) + self.assertIn("Unable to determine", malformed.stderr) + + def test_rejects_a_broken_github_cli(self) -> None: + result = self.run_check("", exit_code=1) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unable to run the GitHub CLI", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-github-release-integrity.sh b/scripts/verify-github-release-integrity.sh index a5b2df31a..3d0227543 100755 --- a/scripts/verify-github-release-integrity.sh +++ b/scripts/verify-github-release-integrity.sh @@ -7,15 +7,15 @@ set -euo pipefail -if [ "$#" -lt 2 ] || [ "$#" -gt 4 ]; then - echo "Usage: $0 [expected-release-id] [expected-source-sha]" >&2 +if [ "$#" -ne 4 ]; then + echo "Usage: $0 " >&2 exit 1 fi TAG="$1" REPO="$2" -EXPECTED_RELEASE_ID="${3:-}" -EXPECTED_SOURCE_SHA="${4:-}" +EXPECTED_RELEASE_ID="$3" +EXPECTED_SOURCE_SHA="$4" ATTESTATION_ATTEMPTS="${PULSE_RELEASE_ATTESTATION_ATTEMPTS:-12}" ATTESTATION_RETRY_DELAY="${PULSE_RELEASE_ATTESTATION_RETRY_DELAY:-5}" @@ -27,11 +27,11 @@ if [[ ! "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then echo "Invalid GitHub repository: ${REPO}" >&2 exit 1 fi -if [ -n "$EXPECTED_RELEASE_ID" ] && [[ ! "$EXPECTED_RELEASE_ID" =~ ^[0-9]+$ ]]; then +if [[ ! "$EXPECTED_RELEASE_ID" =~ ^[0-9]+$ ]]; then echo "Invalid expected release id: ${EXPECTED_RELEASE_ID}" >&2 exit 1 fi -if [ -n "$EXPECTED_SOURCE_SHA" ] && [[ ! "$EXPECTED_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then +if [[ ! "$EXPECTED_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then echo "Invalid expected source SHA: ${EXPECTED_SOURCE_SHA}" >&2 exit 1 fi @@ -51,10 +51,15 @@ for command in gh jq; do fi done +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"${SCRIPT_DIR}/require-safe-gh-attestation.sh" + release_json="$(mktemp)" attestation_json="$(mktemp)" activation_dir="$(mktemp -d)" activation_asset="${activation_dir}/release-activation.json" +checksums_asset="${activation_dir}/checksums.txt" +SIGNER_WORKFLOW="github.com/${REPO}/.github/workflows/create-release.yml" cleanup() { rm -f "$release_json" "$attestation_json" rm -rf "$activation_dir" @@ -123,8 +128,10 @@ for attempt in $(seq 1 "$ATTESTATION_ATTEMPTS"); do if gh release download "$TAG" \ --repo "$REPO" \ --pattern release-activation.json \ + --pattern checksums.txt \ --dir "$activation_dir" && \ - [ -s "$activation_asset" ]; then + [ -s "$activation_asset" ] && \ + [ -s "$checksums_asset" ]; then downloaded=true break fi @@ -148,7 +155,33 @@ if ! jq -e 'type == "object" or type == "array"' "$attestation_json" >/dev/null; exit 1 fi +# checksums.txt is the transitive identity of the executable release packet: +# every primary binary, archive, installer, chart, and SBOM is named and +# digest-bound there, while their detached signatures are checked separately. +# First bind those exact checksum bytes to the immutable release, then require +# build provenance from the one workflow and source commit authorized to +# assemble the packet. Repository-level provenance alone is intentionally not +# sufficient because other workflows in this repository can issue attestations. +if ! gh release verify-asset "$TAG" "$checksums_asset" \ + --repo "$REPO" --format json > "$attestation_json"; then + echo "GitHub release checksum manifest verification failed for ${TAG}." >&2 + exit 1 +fi +if ! jq -e 'type == "object" or type == "array"' "$attestation_json" >/dev/null; then + echo "GitHub release checksum manifest verification returned malformed JSON for ${TAG}." >&2 + exit 1 +fi +if ! gh attestation verify "$checksums_asset" \ + --repo "$REPO" \ + --signer-workflow "$SIGNER_WORKFLOW" \ + --source-digest "$EXPECTED_SOURCE_SHA" \ + --predicate-type https://slsa.dev/provenance/v1 \ + >/dev/null; then + echo "Release checksum manifest build provenance verification failed for ${TAG}." >&2 + exit 1 +fi + release_id="$(jq -r '.id' "$release_json")" source_sha="$(jq -r '.target_commitish' "$release_json")" asset_count="$(jq -r '.assets | length' "$release_json")" -echo "[OK] GitHub release ${TAG} is immutable, attested, and activation-asset-bound: release_id=${release_id} source_sha=${source_sha} assets=${asset_count}." +echo "[OK] GitHub release ${TAG} is immutable, release-attested, activation-asset-bound, and build-provenance-bound: release_id=${release_id} source_sha=${source_sha} assets=${asset_count}." diff --git a/scripts/verify-release-container-images.sh b/scripts/verify-release-container-images.sh index 80a1f6642..aa85590de 100755 --- a/scripts/verify-release-container-images.sh +++ b/scripts/verify-release-container-images.sh @@ -39,6 +39,9 @@ for command in docker gh jq; do fi done +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"${SCRIPT_DIR}/require-safe-gh-attestation.sh" + resolve_digest() { local reference="$1" local manifest @@ -75,6 +78,7 @@ verify_image() { --bundle-from-oci \ --signer-workflow "$SIGNER_WORKFLOW" \ --source-digest "$SOURCE_SHA" \ + --predicate-type https://slsa.dev/provenance/v1 \ >/dev/null done