diff --git a/.github/workflows/backfill-release-assets.yml b/.github/workflows/backfill-release-assets.yml index 03e366f997..72b2ccf04f 100644 --- a/.github/workflows/backfill-release-assets.yml +++ b/.github/workflows/backfill-release-assets.yml @@ -62,6 +62,7 @@ jobs: - name: Validate published release packet env: + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} REPOSITORY: ${{ github.repository }} TAG: ${{ inputs.tag }} run: | diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 38ee3b84af..68a2d6d1cd 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1232,6 +1232,8 @@ jobs: ./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}" - name: Validate published release packet + env: + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} run: | ./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}" diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f4f1e5cd5d..b7085586eb 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -4313,8 +4313,12 @@ CI release builds, render release installers with that pinned SSH verifier, emit both `.sig` and `.sshsig` sidecars for shipped agent binaries and installer assets, emit a standalone SPDX JSON SBOM for the assembled release packet, upload those security artifacts with the matching -release packet, and fail validation if any published artifact or -`checksums.txt` is missing its `.sshsig` sidecar or if the canonical +release packet, and make post-publication validation authenticate +`checksums.txt` and every listed artifact's `.sshsig` against the configured +`PULSE_UPDATE_SIGNING_PUBLIC_KEY`, not merely test that sidecars are present. +Validation must fail if the trust root is unavailable, if any signature is +invalid, if any published artifact or `checksums.txt` is missing its +`.sshsig` sidecar, or if the canonical release-packet SBOM is absent so published RC/stable downloads can keep the updater and installer trust chain fail-closed instead of downgrading to checksum-only trust and can publish a shareable non-image software inventory diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 463654944f..3805ea06fa 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -1272,10 +1272,12 @@ func TestBackfillReleaseWorkflowRepairsPublishedAssetsWithoutRebuilds(t *testing `SYFT_VERSION="1.42.4"`, `SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"`, `SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"`, - `./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}"`, + `TAG: ${{ inputs.tag }}`, + `REPOSITORY: ${{ github.repository }}`, + `./scripts/backfill-release-assets.sh --tag "${TAG}" --repo "${REPOSITORY}"`, `PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}`, `PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, - `./scripts/validate-published-release.sh "${{ inputs.tag }}" "${{ github.repository }}"`, + `./scripts/validate-published-release.sh "${TAG}" "${REPOSITORY}"`, } for _, needle := range workflowRequired { if !strings.Contains(workflow, needle) { @@ -1389,6 +1391,12 @@ func TestReleaseValidationRequiresSignedSidecars(t *testing.T) { publishedValidator := string(publishedValidatorBytes) publishedRequired := []string{ `RELEASE_SBOM="pulse-${TAG}-release.sbom.spdx.json"`, + `PULSE_UPDATE_SIGNING_PUBLIC_KEY is required to authenticate published release assets.`, + `go -C "$REPO_ROOT" run ./scripts/release_update_key.go public-key-ssh`, + `ssh-keygen -Y verify`, + `-I pulse-installer`, + `-n pulse-install`, + `verify_signature "$CHECKSUMS_PATH" "$CHECKSUMS_SIG_PATH"`, `echo "Failed to download ${RELEASE_SBOM} for ${TAG}" >&2`, `echo "${RELEASE_SBOM} is empty for ${TAG}" >&2`, `CHECKSUMS_SIG_PATH="${TMP_DIR}/checksums.txt.sshsig"`, @@ -1397,7 +1405,8 @@ func TestReleaseValidationRequiresSignedSidecars(t *testing.T) { `sshsig_path="${TMP_DIR}/${filename}.sshsig"`, `"${artifact_url}.sshsig"`, `echo "Failed to download ${filename}.sshsig" >&2`, - `Published release assets for ${TAG} match checksums.txt, *.sha256 files, and required *.sshsig sidecars.`, + `verify_signature "$artifact_path" "$sshsig_path" "$filename"`, + `Published release assets for ${TAG} match authenticated checksums.txt, *.sha256 files, and verified *.sshsig sidecars.`, } for _, needle := range publishedRequired { if !strings.Contains(publishedValidator, needle) { @@ -1421,9 +1430,9 @@ func TestReleaseValidationRequiresSignedSidecars(t *testing.T) { "standalone SPDX JSON SBOM", "already-published packet", "derived integrity assets", - "and fail validation if", - "published artifact or", - "`checksums.txt` is missing its `.sshsig` sidecar", + "make post-publication validation authenticate", + "every listed artifact's `.sshsig` against the configured", + "Validation must fail if the trust root is unavailable", "release-packet SBOM is absent", "download endpoints must return checksum and signature headers", "must disable Go's automatic VCS stamping", diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 3ccf65bb06..e3b50a1b1d 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1568,6 +1568,18 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}"', content) self.assertIn("PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", content) + self.assertIn( + normalize_ws( + """ + - name: Validate published release packet + env: + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + run: | + ./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}" + """ + ), + normalize_ws(content), + ) self.assertNotIn("pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", docker_build) self.assertIn("Validate installer signing key pins", candidate_workflow) self.assertIn("timeout-minutes: 60", candidate_workflow) @@ -1735,6 +1747,20 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('./scripts/backfill-release-assets.sh --tag "${TAG}" --repo "${REPOSITORY}"', backfill_workflow) self.assertIn('./scripts/validate-published-release.sh "${TAG}" "${REPOSITORY}"', backfill_workflow) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", backfill_workflow) + self.assertIn( + normalize_ws( + """ + - name: Validate published release packet + env: + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + REPOSITORY: ${{ github.repository }} + TAG: ${{ inputs.tag }} + run: | + ./scripts/validate-published-release.sh "${TAG}" "${REPOSITORY}" + """ + ), + normalize_ws(backfill_workflow), + ) self.assertIn("./scripts/prepare-release-container-context.sh", candidate_workflow) self.assertIn('test "${actual_server}" = "${expected_server}"', docker_build) self.assertIn('test "${actual_agent}" = "${expected_agent}"', docker_build) diff --git a/scripts/tests/test_validate_published_release.py b/scripts/tests/test_validate_published_release.py new file mode 100644 index 0000000000..b568b51a3b --- /dev/null +++ b/scripts/tests/test_validate_published_release.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "validate-published-release.sh" +TAG = "v9.9.9" +SBOM = f"pulse-{TAG}-release.sbom.spdx.json" + + +@unittest.skipUnless(shutil.which("ssh-keygen"), "ssh-keygen is required") +class ValidatePublishedReleaseTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + self.assets = self.root / "assets" + self.bin_dir = self.root / "bin" + self.assets.mkdir() + self.bin_dir.mkdir() + + self.private_key = self.root / "release-key" + subprocess.run( + [ + "ssh-keygen", + "-q", + "-t", + "ed25519", + "-N", + "", + "-f", + str(self.private_key), + ], + check=True, + ) + self.ssh_public_key = self.private_key.with_suffix(".pub").read_text( + encoding="utf-8" + ).strip() + + payloads = { + SBOM: b'{"spdxVersion":"SPDX-2.3"}\n', + "install.sh": b"#!/bin/sh\necho pulse\n", + } + checksum_lines: list[str] = [] + for name, content in payloads.items(): + (self.assets / name).write_bytes(content) + digest = hashlib.sha256(content).hexdigest() + checksum_lines.append(f"{digest} {name}\n") + (self.assets / f"{name}.sha256").write_text( + f"{digest} {name}\n", encoding="utf-8" + ) + self.sign(self.assets / name) + + checksums = self.assets / "checksums.txt" + checksums.write_text("".join(checksum_lines), encoding="utf-8") + self.sign(checksums) + self.write_fake_tools() + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def sign(self, path: Path) -> None: + subprocess.run( + [ + "ssh-keygen", + "-q", + "-Y", + "sign", + "-f", + str(self.private_key), + "-n", + "pulse-install", + str(path), + ], + check=True, + stdout=subprocess.DEVNULL, + ) + path.with_suffix(path.suffix + ".sig").replace( + path.with_suffix(path.suffix + ".sshsig") + ) + + def write_fake_tools(self) -> None: + curl = self.bin_dir / "curl" + curl.write_text( + """#!/usr/bin/env bash +set -euo pipefail +for arg in "$@"; do url="$arg"; done +name="${url##*/}" +cat "${FAKE_RELEASE_ASSETS}/${name}" +""", + encoding="utf-8", + ) + curl.chmod(0o755) + + go = self.bin_dir / "go" + go.write_text( + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "${FAKE_SSH_PUBLIC_KEY}" +""", + encoding="utf-8", + ) + go.chmod(0o755) + + def run_validator( + self, *, include_key: bool = True + ) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.update( + { + "PATH": f"{self.bin_dir}:{env['PATH']}", + "FAKE_RELEASE_ASSETS": str(self.assets), + "FAKE_SSH_PUBLIC_KEY": self.ssh_public_key, + } + ) + if include_key: + env["PULSE_UPDATE_SIGNING_PUBLIC_KEY"] = "test-configured-key" + else: + env.pop("PULSE_UPDATE_SIGNING_PUBLIC_KEY", None) + return subprocess.run( + [str(SCRIPT), TAG, "example/Pulse"], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + def test_authenticates_manifest_and_each_artifact(self) -> None: + result = self.run_validator() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("match authenticated checksums.txt", result.stdout) + self.assertIn("verified *.sshsig sidecars", result.stdout) + + def test_rejects_forged_artifact_signature(self) -> None: + (self.assets / "install.sh.sshsig").write_text( + "not a signature\n", encoding="utf-8" + ) + result = self.run_validator() + self.assertNotEqual(result.returncode, 0) + self.assertIn("SSH signature verification failed for install.sh", result.stderr) + + def test_rejects_forged_checksum_manifest_before_using_it(self) -> None: + with (self.assets / "checksums.txt").open("a", encoding="utf-8") as handle: + handle.write(f"{'0' * 64} attacker-payload\n") + result = self.run_validator() + self.assertNotEqual(result.returncode, 0) + self.assertIn("SSH signature verification failed for checksums.txt", result.stderr) + self.assertNotIn("attacker-payload", result.stdout) + + def test_requires_configured_release_trust_root(self) -> None: + result = self.run_validator(include_key=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY is required", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate-published-release.sh b/scripts/validate-published-release.sh index bf6d9638e9..73e214fdce 100755 --- a/scripts/validate-published-release.sh +++ b/scripts/validate-published-release.sh @@ -2,10 +2,10 @@ # Remote release validator. # Downloads the published (or draft) assets straight from GitHub Releases, -# recalculates their SHA256 sums, and ensures checksums.txt, the *.sha256 -# helper files, and the required *.sshsig sidecars match the live release -# packet. This prevents broken updates when artifacts are re-uploaded without -# regenerating checksums or their pinned signature sidecars (see issue #698). +# authenticates checksums.txt against the configured release trust root, +# recalculates every listed SHA256 sum, and authenticates each artifact's +# *.sshsig sidecar. This prevents broken or forged updates when artifacts are +# re-uploaded without regenerating their integrity metadata (see issue #698). set -euo pipefail @@ -18,11 +18,52 @@ fi TAG="$1" REPO="${2:-rcourtman/Pulse}" BASE_URL="https://github.com/${REPO}/releases/download/${TAG}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT curl_args=(curl -fsSL --connect-timeout 10 --max-time 600 --retry 3 --retry-delay 2 --retry-all-errors) +for command in curl go sha256sum ssh-keygen; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "${command} is required to authenticate published release assets." >&2 + exit 1 + fi +done + +UPDATE_PUBLIC_KEY="$(printf '%s' "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" | tr -d '\r\n[:space:]')" +if [[ -z "$UPDATE_PUBLIC_KEY" ]]; then + echo "PULSE_UPDATE_SIGNING_PUBLIC_KEY is required to authenticate published release assets." >&2 + exit 1 +fi + +if ! UPDATE_SSH_PUBLIC_KEY="$( + go -C "$REPO_ROOT" run ./scripts/release_update_key.go public-key-ssh \ + --public-key "$UPDATE_PUBLIC_KEY" \ + --comment pulse-installer +)"; then + echo "PULSE_UPDATE_SIGNING_PUBLIC_KEY is not a valid release signing public key." >&2 + exit 1 +fi +ALLOWED_SIGNERS_PATH="${TMP_DIR}/allowed_signers" +printf 'pulse-installer namespaces="pulse-install" %s\n' "$UPDATE_SSH_PUBLIC_KEY" >"$ALLOWED_SIGNERS_PATH" + +verify_signature() { + local payload_path="$1" + local signature_path="$2" + local label="$3" + + if ! ssh-keygen -Y verify \ + -f "$ALLOWED_SIGNERS_PATH" \ + -I pulse-installer \ + -n pulse-install \ + -s "$signature_path" <"$payload_path" >/dev/null 2>&1; then + echo "SSH signature verification failed for ${label}." >&2 + return 1 + fi +} + CHECKSUMS_PATH="${TMP_DIR}/checksums.txt" echo "Downloading ${BASE_URL}/checksums.txt" if ! "${curl_args[@]}" "${BASE_URL}/checksums.txt" >"$CHECKSUMS_PATH"; then @@ -30,6 +71,20 @@ if ! "${curl_args[@]}" "${BASE_URL}/checksums.txt" >"$CHECKSUMS_PATH"; then exit 1 fi +CHECKSUMS_SIG_PATH="${TMP_DIR}/checksums.txt.sshsig" +echo "Downloading ${BASE_URL}/checksums.txt.sshsig" +if ! "${curl_args[@]}" "${BASE_URL}/checksums.txt.sshsig" >"$CHECKSUMS_SIG_PATH"; then + echo "Failed to download checksums.txt.sshsig for ${TAG}" >&2 + exit 1 +fi +if [[ ! -s "$CHECKSUMS_SIG_PATH" ]]; then + echo "checksums.txt.sshsig is empty for ${TAG}" >&2 + exit 1 +fi +if ! verify_signature "$CHECKSUMS_PATH" "$CHECKSUMS_SIG_PATH" "checksums.txt for ${TAG}"; then + exit 1 +fi + RELEASE_SBOM="pulse-${TAG}-release.sbom.spdx.json" if ! awk '{print $2}' "$CHECKSUMS_PATH" | grep -Fx "$RELEASE_SBOM" >/dev/null 2>&1; then echo "checksums.txt does not list ${RELEASE_SBOM} for ${TAG}" >&2 @@ -47,17 +102,6 @@ if [[ ! -s "$RELEASE_SBOM_PATH" ]]; then exit 1 fi -CHECKSUMS_SIG_PATH="${TMP_DIR}/checksums.txt.sshsig" -echo "Downloading ${BASE_URL}/checksums.txt.sshsig" -if ! "${curl_args[@]}" "${BASE_URL}/checksums.txt.sshsig" >"$CHECKSUMS_SIG_PATH"; then - echo "Failed to download checksums.txt.sshsig for ${TAG}" >&2 - exit 1 -fi -if [[ ! -s "$CHECKSUMS_SIG_PATH" ]]; then - echo "checksums.txt.sshsig is empty for ${TAG}" >&2 - exit 1 -fi - status=0 while read -r checksum filename _; do @@ -68,15 +112,28 @@ while read -r checksum filename _; do status=1 continue fi + if [[ ! "$checksum" =~ ^[0-9a-f]{64}$ ]]; then + echo "Malformed checksums line (invalid SHA-256 for ${filename}): ${checksum}" >&2 + status=$((status + 1)) + continue + fi + if [[ ! "$filename" =~ ^[A-Za-z0-9._+-]+$ ]]; then + echo "Unsafe release asset filename in checksums.txt: ${filename}" >&2 + status=$((status + 1)) + continue + fi artifact_url="${BASE_URL}/${filename}" echo "Verifying ${filename}..." - if ! actual_checksum=$("${curl_args[@]}" "$artifact_url" | sha256sum | awk '{print $1}'); then + artifact_path="${TMP_DIR}/${filename}" + if ! "${curl_args[@]}" "$artifact_url" >"$artifact_path"; then echo "Failed to download ${filename}" >&2 status=$((status + 1)) + rm -f "$artifact_path" continue fi + actual_checksum="$(sha256sum "$artifact_path" | awk '{print $1}')" if [[ "$actual_checksum" != "$checksum" ]]; then echo "Checksum mismatch for ${filename}: expected ${checksum}, got ${actual_checksum}" >&2 @@ -87,6 +144,7 @@ while read -r checksum filename _; do if ! sha_content=$("${curl_args[@]}" "$sha_url" | tr -d '\r' | sed 's/[[:space:]]*$//'); then echo "Failed to download ${filename}.sha256" >&2 status=$((status + 1)) + rm -f "$artifact_path" continue fi @@ -100,12 +158,19 @@ while read -r checksum filename _; do if ! "${curl_args[@]}" "${artifact_url}.sshsig" >"$sshsig_path"; then echo "Failed to download ${filename}.sshsig" >&2 status=$((status + 1)) + rm -f "$artifact_path" "$sshsig_path" continue fi if [[ ! -s "$sshsig_path" ]]; then echo "${filename}.sshsig is empty" >&2 status=$((status + 1)) + rm -f "$artifact_path" + continue fi + if ! verify_signature "$artifact_path" "$sshsig_path" "$filename"; then + status=$((status + 1)) + fi + rm -f "$artifact_path" done < "$CHECKSUMS_PATH" if [[ "$status" -ne 0 ]]; then @@ -113,4 +178,4 @@ if [[ "$status" -ne 0 ]]; then exit 1 fi -echo "Published release assets for ${TAG} match checksums.txt, *.sha256 files, and required *.sshsig sidecars." +echo "Published release assets for ${TAG} match authenticated checksums.txt, *.sha256 files, and verified *.sshsig sidecars."