mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Bind release activation to trusted provenance
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
Executable
+42
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user