Bind release activation to trusted provenance

This commit is contained in:
pulse-triage[bot]
2026-08-29 23:40:57 +01:00
parent d06ffc233d
commit 718de25b2b
7 changed files with 219 additions and 14 deletions
+9 -2
View File
@@ -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 <tag> --repo
rcourtman/Pulse` validates GitHub's signed release attestation and `gh release
verify-asset <tag> <downloaded-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
@@ -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()
@@ -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")
+42
View File
@@ -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
@@ -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()
+41 -8
View File
@@ -7,15 +7,15 @@
set -euo pipefail
if [ "$#" -lt 2 ] || [ "$#" -gt 4 ]; then
echo "Usage: $0 <tag> <owner/repo> [expected-release-id] [expected-source-sha]" >&2
if [ "$#" -ne 4 ]; then
echo "Usage: $0 <tag> <owner/repo> <expected-release-id> <expected-source-sha>" >&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}."
@@ -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