From 1896166bc152b3096524cc1da13546b5627a74e2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 6 Aug 2026 15:24:50 +0100 Subject: [PATCH] ci(signing): add isolated SignPath test proof --- .github/workflows/signpath-test-signing.yml | 237 ++++++++++++++++++ .../release_promotion_policy_test.py | 37 +++ 2 files changed, 274 insertions(+) create mode 100644 .github/workflows/signpath-test-signing.yml diff --git a/.github/workflows/signpath-test-signing.yml b/.github/workflows/signpath-test-signing.yml new file mode 100644 index 000000000..8fcea1e9a --- /dev/null +++ b/.github/workflows/signpath-test-signing.yml @@ -0,0 +1,237 @@ +name: SignPath Test Signing Proof +run-name: SignPath test-signing proof for v${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + version: + description: 'Version label embedded in the test binaries (without leading v)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: signpath-test-signing-proof + cancel-in-progress: false + +jobs: + test-signing-proof: + name: SignPath Test Signing Proof (Never Publish) + runs-on: windows-2025 + timeout-minutes: 50 + permissions: + actions: read + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + + - name: Verify isolated test-signing configuration + shell: pwsh + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }} + SIGNPATH_ARTIFACT_CONFIGURATION_SLUG: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} + SIGNPATH_RELEASE_POLICY_SLUG: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }} + TEST_VERSION: ${{ inputs.version }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:GITHUB_REF_NAME -ne 'main') { + throw "SignPath test proof must run from main, not $env:GITHUB_REF_NAME." + } + foreach ($name in @( + 'SIGNPATH_API_TOKEN', + 'SIGNPATH_ORGANIZATION_ID', + 'SIGNPATH_PROJECT_SLUG', + 'SIGNPATH_ARTIFACT_CONFIGURATION_SLUG' + )) { + if ([string]::IsNullOrWhiteSpace((Get-Item "Env:$name").Value)) { + throw "Missing required $name." + } + } + if ($env:SIGNPATH_RELEASE_POLICY_SLUG -ne 'release-signing') { + throw 'The canonical SIGNPATH_SIGNING_POLICY_SLUG must remain release-signing.' + } + if ($env:TEST_VERSION -notmatch '^6\.[0-9]+\.[0-9]+(?:-(?:rc|alpha|beta)\.[0-9]+)?$') { + throw 'Version must be a valid Pulse v6 release or prerelease version without a leading v.' + } + $repositoryVersion = (Get-Content VERSION -Raw).Trim() + if ($env:TEST_VERSION -ne $repositoryVersion) { + throw "Test version $env:TEST_VERSION does not match repository VERSION $repositoryVersion." + } + + - name: Build unsigned Windows agent binaries + shell: pwsh + env: + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + TEST_VERSION: ${{ inputs.version }} + run: | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:PULSE_UPDATE_SIGNING_PUBLIC_KEY)) { + throw 'Missing required PULSE_UPDATE_SIGNING_PUBLIC_KEY.' + } + New-Item -ItemType Directory -Path unsigned-native-agent-binaries -Force | Out-Null + $ldflags = & bash ./scripts/release_ldflags.sh agent --version "v$env:TEST_VERSION" --update-public-keys $env:PULSE_UPDATE_SIGNING_PUBLIC_KEY + foreach ($arch in @('amd64', 'arm64', '386')) { + $env:GOOS = 'windows' + $env:GOARCH = $arch + $env:CGO_ENABLED = '0' + $output = "unsigned-native-agent-binaries/pulse-agent-windows-$arch.exe" + go build -buildvcs=false -trimpath -ldflags="$ldflags" -o $output ./cmd/pulse-agent + if ($LASTEXITCODE -ne 0) { throw "Go build failed for Windows $arch." } + } + + - name: Upload unsigned SignPath test input + id: upload-unsigned-windows + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unsigned-signpath-test-${{ github.sha }}-${{ inputs.version }} + path: unsigned-native-agent-binaries/ + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + - name: Submit SignPath test-signing request + id: signpath + uses: signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: test-signing + artifact-configuration-slug: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} + github-artifact-id: ${{ steps.upload-unsigned-windows.outputs.artifact-id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + wait-for-completion: true + wait-for-completion-timeout-in-seconds: 2400 + output-artifact-directory: signpath-test-output + parameters: | + version: ${{ toJSON(inputs.version) }} + + - name: Verify test signatures and write non-production evidence + shell: pwsh + env: + SIGNPATH_SIGNING_REQUEST_ID: ${{ steps.signpath.outputs.signing-request-id }} + SIGNPATH_SIGNING_REQUEST_URL: ${{ steps.signpath.outputs.signing-request-web-url }} + SIGNPATH_INPUT_ARTIFACT_ID: ${{ steps.upload-unsigned-windows.outputs.artifact-id }} + SIGNPATH_ARTIFACT_CONFIGURATION_SLUG: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} + TEST_VERSION: ${{ inputs.version }} + run: | + $ErrorActionPreference = 'Stop' + $sourceDir = (Resolve-Path 'signpath-test-output').Path + $expectedNames = @( + 'pulse-agent-windows-386.exe', + 'pulse-agent-windows-amd64.exe', + 'pulse-agent-windows-arm64.exe' + ) + $actualNames = @( + Get-ChildItem $sourceDir -File -Recurse | + ForEach-Object { [IO.Path]::GetRelativePath($sourceDir, $_.FullName).Replace('\', '/') } | + Sort-Object + ) + if (Compare-Object $expectedNames $actualNames) { + throw "SignPath returned an unexpected file set: $($actualNames -join ', ')." + } + + $probePath = Join-Path $sourceDir $expectedNames[0] + $probeSignature = Get-AuthenticodeSignature $probePath + if ($null -eq $probeSignature.SignerCertificate) { + throw 'The SignPath test output does not contain an Authenticode signer certificate.' + } + + $certificate = $probeSignature.SignerCertificate + $thumbprint = $certificate.Thumbprint + $certificatePath = Join-Path $env:RUNNER_TEMP 'signpath-test-signer.cer' + [IO.File]::WriteAllBytes( + $certificatePath, + $certificate.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert) + ) + + Import-Certificate -FilePath $certificatePath -CertStoreLocation Cert:\CurrentUser\Root | Out-Null + Import-Certificate -FilePath $certificatePath -CertStoreLocation Cert:\CurrentUser\TrustedPublisher | Out-Null + try { + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Filter signtool.exe -Recurse | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + if ([string]::IsNullOrWhiteSpace($signtool)) { + throw 'signtool.exe was not found on the Windows runner.' + } + + $files = @() + foreach ($name in $expectedNames) { + $path = Join-Path $sourceDir $name + & $signtool verify /pa /v $path + if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed for $name." } + $signature = Get-AuthenticodeSignature $path + if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) { + throw "Invalid Authenticode status for ${name}: $($signature.Status)." + } + if ($signature.SignerCertificate.Thumbprint -ne $thumbprint) { + throw "SignPath test output used inconsistent signer certificates for $name." + } + $files += [ordered]@{ + name = $name + sha256 = (Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() + } + } + + $evidence = [ordered]@{ + schemaVersion = 1 + nonProduction = $true + signedArtifactsPublished = $false + signedArtifactsUploadedAsGitHubArtifact = $false + signingPolicy = 'test-signing' + artifactConfiguration = $env:SIGNPATH_ARTIFACT_CONFIGURATION_SLUG + version = $env:TEST_VERSION + sourceSha = $env:GITHUB_SHA + sourceRef = $env:GITHUB_REF + workflowRunUrl = "https://github.com/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID" + signPathSigningRequestId = $env:SIGNPATH_SIGNING_REQUEST_ID + signPathSigningRequestUrl = $env:SIGNPATH_SIGNING_REQUEST_URL + githubInputArtifactId = $env:SIGNPATH_INPUT_ARTIFACT_ID + signer = [ordered]@{ + subject = $certificate.Subject + issuer = $certificate.Issuer + thumbprint = $thumbprint + notBefore = $certificate.NotBefore.ToUniversalTime().ToString('o') + notAfter = $certificate.NotAfter.ToUniversalTime().ToString('o') + } + files = $files + } + $evidence | ConvertTo-Json -Depth 6 | Set-Content signpath-test-signing-evidence.json -Encoding utf8NoBOM + } + finally { + Remove-Item "Cert:\CurrentUser\Root\$thumbprint" -Force -ErrorAction SilentlyContinue + Remove-Item "Cert:\CurrentUser\TrustedPublisher\$thumbprint" -Force -ErrorAction SilentlyContinue + } + + - name: Upload non-production evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: signpath-test-signing-evidence-${{ github.sha }}-${{ inputs.version }} + path: signpath-test-signing-evidence.json + if-no-files-found: error + retention-days: 30 + + - name: Record non-production boundary + shell: pwsh + run: | + @" + ### SignPath test-signing proof + + - Source: ``$env:GITHUB_SHA`` on ``main`` + - Policy: ``test-signing`` + - Result: signature verification passed for the three expected Windows agents + - Publication: test-signed binaries were not uploaded as a GitHub artifact or published + + The test certificate is untrusted and this run is not production-signing evidence. + "@ | Add-Content $env:GITHUB_STEP_SUMMARY diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index d0127d2ea..c742a206e 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -501,6 +501,43 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertNotIn("docs/releases/RELEASE_NOTES_v6.md", demo_copy) self.assertNotIn("docs/releases/V6_RC_OPERATOR_SUPPORT_PACK.md", demo_copy) + def test_signpath_test_signing_workflow_is_non_publishing_and_test_only(self) -> None: + workflow = read(".github/workflows/signpath-test-signing.yml") + + self.assertIn("workflow_dispatch:", workflow) + self.assertNotIn("pull_request:", workflow) + self.assertNotIn("schedule:", workflow) + self.assertNotIn("push:", workflow) + self.assertIn("SignPath Test Signing Proof (Never Publish)", workflow) + self.assertIn("if ($env:GITHUB_REF_NAME -ne 'main')", workflow) + self.assertNotIn("'${{ inputs.version }}'", workflow) + self.assertIn("does not match repository VERSION", workflow) + self.assertIn("signing-policy-slug: test-signing", workflow) + self.assertNotIn( + "signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}", + workflow, + ) + self.assertIn( + "The canonical SIGNPATH_SIGNING_POLICY_SLUG must remain release-signing.", + workflow, + ) + self.assertIn( + "signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2", + workflow, + ) + self.assertIn("signedArtifactsPublished = $false", workflow) + self.assertIn("signedArtifactsUploadedAsGitHubArtifact = $false", workflow) + self.assertIn("nonProduction = $true", workflow) + self.assertIn("path: signpath-test-signing-evidence.json", workflow) + self.assertNotIn("gh release", workflow) + self.assertNotIn("release-candidate", workflow) + for name in ( + "pulse-agent-windows-amd64.exe", + "pulse-agent-windows-arm64.exe", + "pulse-agent-windows-386.exe", + ): + self.assertIn(name, workflow) + def test_update_demo_server_workflow_uses_stable_tag_example(self) -> None: workflow = read(".github/workflows/update-demo-server.yml") self.assertIn("Stable release tag to deploy (e.g., v6.0.0)", workflow)