Skip to content

Commit cf8eddf

Browse files
committed
ci: add CTL policy experiment to the Windows runner trust probe
Record the Windows build, and ask certutil whether the local Disallowed CTL matches each compromised certificate. On GitHub-hosted runners with DisableRootAutoUpdate=1, finish with an informational experiment: set EnableDisallowedCertAutoUpdate=1, restart CryptSvc and recount explicit distrust in a fresh pwsh process; if that does not help, also remove DisableRootAutoUpdate and recount. The experiment changes machine policy, so it runs after all gating checks and never on self-hosted runners.
1 parent 054d973 commit cf8eddf

1 file changed

Lines changed: 96 additions & 1 deletion

File tree

‎ci/tools/runner_trust_probe.ps1‎

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
$ErrorActionPreference = 'Stop'
99

10-
. (Join-Path $PSScriptRoot '..\..\.github\actions\upgrade-ca-certificates\emudhra_intermediates.ps1')
10+
$EmudhraHelperPath = Join-Path $PSScriptRoot '..\..\.github\actions\upgrade-ca-certificates\emudhra_intermediates.ps1'
11+
. $EmudhraHelperPath
1112

1213
$EmSignG3Hex = '86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B'
1314
# One host per path: via emSign ECC Root CA - G3, and via the Sectigo
@@ -95,6 +96,74 @@ function Record-CertutilCtl([string]$Name, [string[]]$CertutilArgs) {
9596
Record-Check $Name 'INFO' $details
9697
}
9798

99+
# Asks certutil whether the local Disallowed CTL lists each compromised
100+
# certificate's key. certutil prints "Certificate match found" and exits 0 on a
101+
# match, and exits 0x80094005 when the CTL has no entry for the certificate.
102+
function Record-CtlMatch([string]$Name) {
103+
$noMatchExitCode = [int]0x80094005
104+
$certs = @(Get-EmudhraIntermediates)
105+
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) "emudhra-ctl-match-$PID"
106+
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
107+
try {
108+
$matched = @(); $notMatched = @(); $unclear = @()
109+
for ($i = 0; $i -lt $certs.Count; $i++) {
110+
$certFile = Join-Path $workDir "cert$i.crt"
111+
[System.IO.File]::WriteAllBytes($certFile, $certs[$i].RawData)
112+
$output = @(& certutil.exe -verifyCTL Disallowed $workDir $certFile 2>&1 | ForEach-Object { "$_" })
113+
$exitCode = $LASTEXITCODE
114+
if ($exitCode -eq 0 -and ($output -match 'match found')) {
115+
$matched += $certs[$i].EmudhraLabel
116+
} elseif ($exitCode -eq $noMatchExitCode) {
117+
$notMatched += $certs[$i].EmudhraLabel
118+
} else {
119+
$unclear += "$($certs[$i].EmudhraLabel) (exit $exitCode)"
120+
if ($unclear.Count -eq 1) {
121+
Write-Host "::group::certutil -verifyCTL Disallowed for $($certs[$i].EmudhraLabel) (exit $exitCode)"
122+
$output | ForEach-Object { Write-Host $_ }
123+
Write-Host '::endgroup::'
124+
}
125+
}
126+
}
127+
$details = "$($matched.Count) of $($certs.Count) matched"
128+
if ($notMatched.Count -gt 0) { $details += "; no match: $($notMatched -join '; ')" }
129+
if ($unclear.Count -gt 0) { $details += "; unexpected result: $($unclear -join '; ')" }
130+
Record-Check $Name 'INFO' $details
131+
} finally {
132+
$certs | ForEach-Object { $_.Dispose() }
133+
Remove-Item -LiteralPath $workDir -Recurse -Force
134+
}
135+
}
136+
137+
# Counts the compromised certificates that Windows reports as explicitly
138+
# distrusted, in a fresh pwsh process so no chain state cached by this
139+
# process carries over.
140+
function Get-ExplicitlyDistrustedCountFresh {
141+
$command = ". '$EmudhraHelperPath'; " +
142+
'$certs = @(Get-EmudhraIntermediates); ' +
143+
'try { @($certs | Where-Object { Test-ExplicitlyDistrusted $_ }).Count } ' +
144+
'finally { $certs | ForEach-Object { $_.Dispose() } }'
145+
$output = @(& (Get-Process -Id $PID).Path -NoProfile -NonInteractive -Command $command 2>&1 | ForEach-Object { "$_" })
146+
if ($LASTEXITCODE -ne 0 -or $output.Count -ne 1 -or $output[0] -notmatch '^\d+$') {
147+
throw "Fresh distrust check failed (exit $LASTEXITCODE): $($output -join ' ')"
148+
}
149+
return [int]$output[0]
150+
}
151+
152+
# Applies one CTL policy change, restarts the CryptSvc service that maintains
153+
# the CTLs, and records the explicit-distrust count and certutil matches
154+
# observed afterwards.
155+
function Invoke-PolicyExperimentStep([string]$Label, [scriptblock]$Change) {
156+
& $Change
157+
Restart-Service -Name CryptSvc -Force
158+
$policy = "DisableRootAutoUpdate=$(Get-RegistryValueText $CtlPolicyPath 'DisableRootAutoUpdate'), " +
159+
"EnableDisallowedCertAutoUpdate=$(Get-RegistryValueText $CtlPolicyPath 'EnableDisallowedCertAutoUpdate')"
160+
$count = Get-ExplicitlyDistrustedCountFresh
161+
# Out-Host keeps the recorded rows out of this function's return value.
162+
Record-Check "Experiment: $Label" 'INFO' "$count of $EmudhraIntermediatesCount explicitly distrusted after CryptSvc restart ($policy)" | Out-Host
163+
Record-CtlMatch "Experiment: $Label, CTL matches" | Out-Host
164+
return $count
165+
}
166+
98167
$GatingFailures = 0
99168

100169
Append-Summary "### $ProbeLabel"
@@ -116,6 +185,10 @@ Record-Check 'emSign ECC Root CA - G3 store state' 'INFO' "LocalMachine\Root=$in
116185
# 2. CTL update state (informational). Microsoft delivers its eMudhra block
117186
# through the Disallowed CTL, which reaches a machine only through automatic
118187
# CTL updates or an explicit import.
188+
Invoke-Diagnostic 'Windows version' {
189+
$cv = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
190+
Record-Check 'Windows version' 'INFO' "$($cv.ProductName) $($cv.DisplayVersion), build $($cv.CurrentBuild).$($cv.UBR), runner environment $env:RUNNER_ENVIRONMENT"
191+
}
119192
Invoke-Diagnostic 'CTL auto-update policy' {
120193
Record-Check 'CTL auto-update policy' 'INFO' ("DisableRootAutoUpdate=$(Get-RegistryValueText $CtlPolicyPath 'DisableRootAutoUpdate'), " +
121194
"EnableDisallowedCertAutoUpdate=$(Get-RegistryValueText $CtlPolicyPath 'EnableDisallowedCertAutoUpdate')")
@@ -126,6 +199,7 @@ Invoke-Diagnostic 'CTL auto-update registry state' {
126199
"DisallowedCertEncodedCtl=$(Get-RegistryValueText $CtlAutoUpdatePath 'DisallowedCertEncodedCtl')")
127200
}
128201
Invoke-Diagnostic 'Local Disallowed CTL' { Record-CertutilCtl 'Local Disallowed CTL' @('-verifyCTL', 'Disallowed') }
202+
Invoke-Diagnostic 'Local Disallowed CTL matches' { Record-CtlMatch 'Local Disallowed CTL matches' }
129203

130204
# 3. All compromised intermediates and cross-signs explicitly distrusted (gating)
131205
$certs = @(Get-EmudhraIntermediates)
@@ -188,6 +262,27 @@ try {
188262
# what they observed.
189263
Invoke-Diagnostic 'Windows Update Disallowed CTL' { Record-CertutilCtl 'Windows Update Disallowed CTL' @('-f', '-verifyCTL', 'DisallowedWU') }
190264

265+
# 6. CTL policy experiment (informational). Tests whether the root auto-update
266+
# policy is why a Disallowed CTL that lists the compromised keys is not applied.
267+
# It changes machine policy and restarts CryptSvc, so it runs last and only on
268+
# ephemeral GitHub-hosted runners where DisableRootAutoUpdate=1.
269+
if ($env:RUNNER_ENVIRONMENT -ne 'github-hosted') {
270+
Record-Check 'Experiment: CTL policy' 'INFO' "Skipped: runner environment is '$env:RUNNER_ENVIRONMENT', not github-hosted"
271+
} elseif ((Get-RegistryValueText $CtlPolicyPath 'DisableRootAutoUpdate') -ne '1') {
272+
Record-Check 'Experiment: CTL policy' 'INFO' 'Skipped: DisableRootAutoUpdate is not 1'
273+
} else {
274+
Invoke-Diagnostic 'Experiment: CTL policy' {
275+
$count = Invoke-PolicyExperimentStep 'EnableDisallowedCertAutoUpdate=1' {
276+
Set-ItemProperty -LiteralPath $CtlPolicyPath -Name 'EnableDisallowedCertAutoUpdate' -Type DWord -Value 1
277+
}
278+
if ($count -lt $EmudhraIntermediatesCount) {
279+
[void](Invoke-PolicyExperimentStep 'DisableRootAutoUpdate removed' {
280+
Remove-ItemProperty -LiteralPath $CtlPolicyPath -Name 'DisableRootAutoUpdate'
281+
})
282+
}
283+
}
284+
}
285+
191286
Append-Summary ""
192287

193288
if ($GatingFailures -gt 0) {

0 commit comments

Comments
 (0)