Files
pulse/.github/workflows/signpath-test-signing.yml
pulse-triage[bot] f61815839f Keep unsigned caches out of privileged workflows
Change-source: pulse-maintainer
2026-09-01 19:29:37 +01:00

342 lines
15 KiB
YAML

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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: false
- 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 ', ')."
}
Write-Host "Confirmed exact SignPath output file set: $($actualNames -join ', ')."
$probePath = Join-Path $sourceDir $expectedNames[0]
Write-Host "Reading the test signer certificate from $($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)
)
function Invoke-BoundedCertUtil {
param(
[Parameter(Mandatory)] [string[]] $Arguments,
[Parameter(Mandatory)] [string] $Description
)
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = Join-Path $env:SystemRoot 'System32\certutil.exe'
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
foreach ($argument in $Arguments) {
[void] $startInfo.ArgumentList.Add($argument)
}
$process = [Diagnostics.Process]::new()
$process.StartInfo = $startInfo
try {
if (-not $process.Start()) {
throw "Failed to start certutil for $Description."
}
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit(30000)) {
$process.Kill($true)
$process.WaitForExit()
throw "certutil timed out after 30 seconds while $Description."
}
$stdoutText = $stdout.GetAwaiter().GetResult()
$stderrText = $stderr.GetAwaiter().GetResult()
if (-not [string]::IsNullOrWhiteSpace($stdoutText)) { Write-Host $stdoutText.TrimEnd() }
if (-not [string]::IsNullOrWhiteSpace($stderrText)) { Write-Warning $stderrText.TrimEnd() }
if ($process.ExitCode -ne 0) {
throw "certutil failed while $Description with exit code $($process.ExitCode)."
}
}
finally {
$process.Dispose()
}
}
$trustedStoreNames = @()
try {
foreach ($storeName in @('Root', 'TrustedPublisher')) {
Write-Host "Adding test signer $thumbprint to LocalMachine/$storeName on the ephemeral runner."
Invoke-BoundedCertUtil -Arguments @(
'-f',
'-addstore',
$storeName,
$certificatePath
) -Description "adding the test signer to LocalMachine/$storeName"
$trustedStoreNames += $storeName
}
Write-Host 'Locating signtool.exe.'
$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.'
}
function Invoke-BoundedSignToolVerify {
param(
[Parameter(Mandatory)] [string] $SignToolPath,
[Parameter(Mandatory)] [string] $ArtifactPath
)
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $SignToolPath
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
foreach ($argument in @('verify', '/pa', '/v', $ArtifactPath)) {
[void] $startInfo.ArgumentList.Add($argument)
}
$process = [Diagnostics.Process]::new()
$process.StartInfo = $startInfo
try {
if (-not $process.Start()) {
throw "Failed to start SignTool verification for $ArtifactPath."
}
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit(90000)) {
$process.Kill($true)
$process.WaitForExit()
throw "SignTool verification timed out after 90 seconds for $ArtifactPath."
}
$stdoutText = $stdout.GetAwaiter().GetResult()
$stderrText = $stderr.GetAwaiter().GetResult()
if (-not [string]::IsNullOrWhiteSpace($stdoutText)) { Write-Host $stdoutText.TrimEnd() }
if (-not [string]::IsNullOrWhiteSpace($stderrText)) { Write-Warning $stderrText.TrimEnd() }
if ($process.ExitCode -ne 0) {
throw "Authenticode verification failed for $ArtifactPath with exit code $($process.ExitCode)."
}
}
finally {
$process.Dispose()
}
}
$files = @()
foreach ($name in $expectedNames) {
$path = Join-Path $sourceDir $name
Write-Host "Verifying Authenticode signature for $name."
Invoke-BoundedSignToolVerify -SignToolPath $signtool -ArtifactPath $path
$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
ephemeralTestTrustScope = 'LocalMachine/Root and LocalMachine/TrustedPublisher'
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 {
foreach ($storeName in $trustedStoreNames) {
Write-Host "Removing test signer $thumbprint from LocalMachine/$storeName."
Invoke-BoundedCertUtil -Arguments @(
'-delstore',
$storeName,
$thumbprint
) -Description "removing the test signer from LocalMachine/$storeName"
}
}
- 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