diff --git a/docs/CODE_SIGNING_POLICY.md b/docs/CODE_SIGNING_POLICY.md index ca345563f..5a9e922ea 100644 --- a/docs/CODE_SIGNING_POLICY.md +++ b/docs/CODE_SIGNING_POLICY.md @@ -74,9 +74,11 @@ Normal stable publication and stable dry runs select `signpath` directly. - 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. + literal signer-identity matcher fix. The shared + `scripts/require-safe-gh-attestation.sh` guard enforces this floor. 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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index da526c93c..f5bc62fdc 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -4522,3 +4522,10 @@ the customer-promotion lease or mutate a floating image tag, Helm index, paid-runtime pointer, or live environment until the check passes. Repository release immutability must therefore be enabled before merging or running this activation path. + +Attestation policy decisions require GitHub CLI 2.97.0 or newer so signer +repository and workflow names are matched literally. The shared verifier must +also bind the downloaded `checksums.txt` bytes to the immutable release and to +SLSA v1 provenance from the exact `create-release.yml` workflow and expected +source SHA. Multi-asset download retries clear both the activation marker and +checksum manifest first so a partial attempt cannot poison every later retry. diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index f52888f04..96b334ebd 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -10253,6 +10253,23 @@ func TestContract_AgentHelperDownloadIsLinuxOnlyAndSeparatelySigned(t *testing.T if response.Code != http.StatusBadRequest { t.Fatalf("non-Linux helper status = %d, want %d", response.Code, http.StatusBadRequest) } + + // The installer fetches this artifact before it has an API credential, so + // the full router must preserve the documented public download boundary. + cfg := newTestConfigWithTokens(t) + cfg.AuthUser = "admin" + cfg.AuthPass = "hashed" + publicRouter := NewRouter(cfg, nil, nil, nil, nil, "v6.0.0") + request = httptest.NewRequest( + http.MethodPost, + "/download/pulse-agent-helper?arch=linux-amd64", + nil, + ) + response = httptest.NewRecorder() + publicRouter.Handler().ServeHTTP(response, request) + if response.Code != http.StatusMethodNotAllowed { + t.Fatalf("public helper download status = %d, want %d", response.Code, http.StatusMethodNotAllowed) + } } func TestContract_AgentRunnerDownloadIsLinuxOnlyAndSeparatelySigned(t *testing.T) { diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index a7e36b691..397a1adb6 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -4742,6 +4742,7 @@ func TestPublicDownloadEndpointsBypassAuth(t *testing.T) { "/install.sh", "/install.ps1", "/download/pulse-agent", + "/download/pulse-agent-helper?arch=linux-amd64", } for idx, path := range paths { diff --git a/scripts/release_control/verify_github_release_integrity_test.py b/scripts/release_control/verify_github_release_integrity_test.py index e8702e6d6..e8502b223 100644 --- a/scripts/release_control/verify_github_release_integrity_test.py +++ b/scripts/release_control/verify_github_release_integrity_test.py @@ -25,6 +25,7 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): asset_verification_succeeds: bool = True, provenance_verification_succeeds: bool = True, gh_version: str = "2.97.0", + partial_download_once: bool = False, ): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -54,6 +55,14 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): while [ "$#" -gt 0 ]; do if [ "$1" = --dir ]; then mkdir -p "$2" + if [ "$PARTIAL_DOWNLOAD_ONCE" = true ] && [ ! -e "$DOWNLOAD_STATE" ]; then + touch "$DOWNLOAD_STATE" + printf '%s\\n' 'abc pulse-v6.5.0-linux-amd64.tar.gz' > "$2/checksums.txt" + exit 1 + fi + if [ -e "$2/release-activation.json" ] || [ -e "$2/checksums.txt" ]; then + exit 66 + fi 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 @@ -79,9 +88,13 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): env.update( { "PATH": f"{root}:{env['PATH']}", - "PULSE_RELEASE_ATTESTATION_ATTEMPTS": "1", + "PULSE_RELEASE_ATTESTATION_ATTEMPTS": ( + "2" if partial_download_once else "1" + ), "PULSE_RELEASE_ATTESTATION_RETRY_DELAY": "0", "GH_VERSION": gh_version, + "PARTIAL_DOWNLOAD_ONCE": str(partial_download_once).lower(), + "DOWNLOAD_STATE": str(root / "download-state"), } ) result = subprocess.run( @@ -166,6 +179,13 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): self.assertIn("checksum manifest build provenance verification failed", result.stderr) self.assertIn("attestation verify", calls) + def test_recovers_from_a_partial_multi_asset_download(self) -> None: + result, calls = self.run_verifier( + self.release(), partial_download_once=True + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls.count("release download"), 2) + 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) diff --git a/scripts/verify-github-release-integrity.sh b/scripts/verify-github-release-integrity.sh index 3d0227543..e5a6e4f35 100755 --- a/scripts/verify-github-release-integrity.sh +++ b/scripts/verify-github-release-integrity.sh @@ -124,7 +124,10 @@ fi # release attestation rather than trusting filename and JSON identity alone. downloaded=false for attempt in $(seq 1 "$ATTESTATION_ATTEMPTS"); do - rm -f "$activation_asset" + # A previous attempt can leave either asset behind after a partial + # download. Clear both because gh release download refuses to overwrite + # existing files unless explicitly told to do so. + rm -f "$activation_asset" "$checksums_asset" if gh release download "$TAG" \ --repo "$REPO" \ --pattern release-activation.json \