Skip to content

Commit 46a5bfc

Browse files
committed
CI: distrust compromised eMudhra intermediates instead of the root
Blocking only emSign ECC Root CA - G3 on Windows leaves the eight compromised intermediates trusted through their Sectigo cross-signs, which chain to AAA Certificate Services. The Windows step now adds all 16 certificates from the disclosure's impacted-intermediates.json (vendored unmodified) to LocalMachine\Disallowed and verifies that chain building reports each one as explicitly distrusted. The probe now gates on the same explicit-distrust check on Windows (the root stays in LocalMachine\Root under Microsoft's own fix, so root presence is informational), verifies the disclosure's 14 served chains offline against the CA bundle on Linux, and tests one live host per path.
1 parent 1b4da6d commit 46a5bfc

22 files changed

Lines changed: 1367 additions & 89 deletions

‎.github/actions/upgrade-ca-certificates/action.yml‎

Lines changed: 35 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
# on GitHub-hosted runners until GitHub ships fixed images.
77
#
88
# On Linux: upgrades ca-certificates via apt so the compromised emSign ECC Root
9-
# CA - G3 anchor is removed from /etc/ssl/certs/ca-certificates.crt.
10-
# On Windows: adds the compromised root to Cert:\LocalMachine\Disallowed so
11-
# schannel-based clients (git, gh, PowerShell/.NET) reject chains through it.
9+
# CA - G3 anchor is removed from /etc/ssl/certs/ca-certificates.crt. Ubuntu's
10+
# bundle does not include AAA Certificate Services, so the Sectigo cross-signs
11+
# do not chain to a trusted root there.
12+
# On Windows: adds the eight compromised intermediates and their eight Sectigo
13+
# cross-signs (impacted-intermediates.json) to Cert:\LocalMachine\Disallowed so
14+
# schannel-based clients (git, gh, PowerShell/.NET) reject chains through them,
15+
# including chains to AAA Certificate Services, which Windows still trusts.
1216
#
1317
# This action is a no-op on self-hosted runners (runner.environment != 'github-hosted')
1418
# because self-hosted runner operators own and update their own host images.
@@ -19,59 +23,48 @@
1923
name: Harden OS certificate trust (eMudhra/emSign CA stopgap)
2024
description: >
2125
Temporary per-job stopgap for CVE-2026-111943. Upgrades ca-certificates on
22-
GitHub-hosted Linux runners and adds the compromised emSign ECC Root CA - G3
23-
to the Disallowed store on GitHub-hosted Windows runners. No-op on self-hosted.
26+
GitHub-hosted Linux runners and adds the compromised eMudhra intermediates
27+
and cross-signs to the Disallowed store on GitHub-hosted Windows runners.
28+
No-op on self-hosted.
2429
2530
runs:
2631
using: composite
2732
steps:
2833

2934
# -------------------------------------------------------------------------
30-
# Windows path: add the compromised root to LocalMachine\Disallowed.
35+
# Windows path: add the compromised intermediates to LocalMachine\Disallowed.
3136
# Takes effect immediately for all schannel-based clients in subsequent steps
3237
# (git, gh, PowerShell/.NET, WinHTTP). Must precede actions/checkout.
3338
# -------------------------------------------------------------------------
34-
- name: Distrust emSign ECC Root CA - G3 (Windows, GitHub-hosted)
39+
- name: Distrust compromised eMudhra intermediates (Windows, GitHub-hosted)
3540
if: ${{ runner.os == 'Windows' && runner.environment == 'github-hosted' }}
3641
shell: pwsh
42+
env:
43+
ACTION_PATH: ${{ github.action_path }}
3744
run: |
3845
$ErrorActionPreference = 'Stop'
39-
$targetFP = '86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B'
40-
41-
# Use -ErrorAction Stop for Root (an unreadable store is a failure, not
42-
# a "clean" state). Use SilentlyContinue for Disallowed, which may be
43-
# empty on a fresh runner. GetCertHashString('SHA256') is available in
44-
# PS7 (shell: pwsh) and avoids the manual SHA256::Create() block.
45-
function Find-CertBySHA256FP([string]$StoreName, [switch]$Required) {
46-
$ea = if ($Required) { 'Stop' } else { 'SilentlyContinue' }
47-
Get-ChildItem "Cert:\LocalMachine\$StoreName" -ErrorAction $ea |
48-
Where-Object { $_.GetCertHashString('SHA256').ToUpper() -eq $targetFP } |
49-
Select-Object -First 1
50-
}
51-
52-
$inRoot = Find-CertBySHA256FP 'Root' -Required
53-
$inDisallowed = Find-CertBySHA256FP 'Disallowed'
54-
55-
if (-not $inRoot) {
56-
Write-Host "::notice::emSign ECC Root CA - G3 absent from LocalMachine\Root; image already fixed."
57-
exit 0
58-
}
59-
if ($inDisallowed) {
60-
Write-Host "::notice::emSign ECC Root CA - G3 already in LocalMachine\Disallowed; image already distrusted."
61-
exit 0
62-
}
63-
64-
Write-Host "::notice::emSign ECC Root CA - G3 in LocalMachine\Root and not in Disallowed; adding to Disallowed store."
65-
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Disallowed', 'LocalMachine')
66-
$store.Open('ReadWrite')
67-
$store.Add($inRoot)
68-
$store.Close()
69-
70-
if (-not (Find-CertBySHA256FP 'Disallowed')) {
71-
Write-Error "emSign ECC Root CA - G3 was not found in LocalMachine\Disallowed after adding; aborting."
72-
exit 1
46+
. (Join-Path $env:ACTION_PATH 'emudhra_intermediates.ps1')
47+
48+
$certs = @(Get-EmudhraIntermediates)
49+
try {
50+
# Adding is idempotent, so no early exit when the image is already fixed.
51+
$store = [System.Security.Cryptography.X509Certificates.X509Store]::new('Disallowed', 'LocalMachine')
52+
$store.Open('ReadWrite')
53+
try {
54+
foreach ($cert in $certs) { $store.Add($cert) }
55+
} finally {
56+
$store.Close()
57+
}
58+
59+
$notDistrusted = @($certs | Where-Object { -not (Test-ExplicitlyDistrusted $_) } | ForEach-Object { $_.EmudhraLabel })
60+
if ($notDistrusted.Count -gt 0) {
61+
Write-Error "Not explicitly distrusted after adding to LocalMachine\Disallowed: $($notDistrusted -join '; ')"
62+
exit 1
63+
}
64+
Write-Host "Distrusted $($certs.Count) compromised eMudhra intermediates and cross-signs."
65+
} finally {
66+
$certs | ForEach-Object { $_.Dispose() }
7367
}
74-
Write-Host "emSign ECC Root CA - G3 successfully added to LocalMachine\Disallowed."
7568
7669
# -------------------------------------------------------------------------
7770
# Linux path: upgrade ca-certificates via apt.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Helpers for the compromised eMudhra intermediates and their Sectigo
6+
# cross-signs listed in impacted-intermediates.json. Dot-sourced by this
7+
# action's Windows step and by ci/tools/runner_trust_probe.ps1.
8+
#
9+
# impacted-intermediates.json is the disclosure's file, unmodified (SHA-256
10+
# f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e). The set
11+
# is closed: new certificates for these keys would have to be issued by
12+
# another CA, and all existing ones are revoked.
13+
14+
$EmudhraIntermediatesPath = Join-Path $PSScriptRoot 'impacted-intermediates.json'
15+
$EmudhraIntermediatesCount = 16
16+
17+
# Returns the certificates as X509Certificate2 objects; callers dispose them.
18+
function Get-EmudhraIntermediates {
19+
$entries = @(Get-Content -LiteralPath $EmudhraIntermediatesPath -Raw | ConvertFrom-Json)
20+
if ($entries.Count -ne $EmudhraIntermediatesCount) {
21+
throw "Expected $EmudhraIntermediatesCount certificates in $EmudhraIntermediatesPath, found $($entries.Count)."
22+
}
23+
foreach ($entry in $entries) {
24+
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPem($entry.pem)
25+
if ($cert.GetCertHashString('SHA256') -ne $entry.sha256.ToUpperInvariant()) {
26+
$cert.Dispose()
27+
throw "SHA-256 mismatch for $($entry.name) (issued by $($entry.issued_by))."
28+
}
29+
Add-Member -InputObject $cert -NotePropertyName EmudhraLabel -NotePropertyValue "$($entry.name) (issued by $($entry.issued_by))"
30+
$cert
31+
}
32+
}
33+
34+
# True if Windows chain building reports the certificate as explicitly
35+
# distrusted. This covers both a local LocalMachine\Disallowed entry and
36+
# Microsoft's automatically updated disallowed list. Revocation checking and
37+
# AIA downloads are off so the result reflects the local trust state only.
38+
function Test-ExplicitlyDistrusted([System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert) {
39+
$explicitDistrust = [System.Security.Cryptography.X509Certificates.X509ChainStatusFlags]::ExplicitDistrust
40+
$chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new()
41+
try {
42+
$chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck
43+
$chain.ChainPolicy.DisableCertificateDownloads = $true
44+
[void]$chain.Build($Cert)
45+
$statuses = @($chain.ChainStatus) + @($chain.ChainElements | ForEach-Object { $_.ChainElementStatus })
46+
return [bool]($statuses | Where-Object { $_.Status.HasFlag($explicitDistrust) })
47+
} finally {
48+
$chain.Dispose()
49+
}
50+
}

0 commit comments

Comments
 (0)