Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,15 @@ paths:
".github/workflows/test-wheel-windows.yml":
ignore:
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
".github/workflows/build-wheel.yml":
ignore:
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
".github/workflows/pre-commit.yml":
ignore:
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
".github/workflows/test-sdist-windows.yml":
ignore:
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
".github/workflows/coverage.yml":
ignore:
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
144 changes: 144 additions & 0 deletions .github/actions/upgrade-ca-certificates/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
#
# SPDX-License-Identifier: Apache-2.0
#
# Temporary stopgap for the eMudhra/emSign CA key compromise (CVE-2026-111943)
# on GitHub-hosted runners until GitHub ships fixed images.
#
# On Linux: upgrades ca-certificates via apt so the compromised emSign ECC Root
# CA - G3 anchor is removed from /etc/ssl/certs/ca-certificates.crt. Ubuntu's
# bundle does not include AAA Certificate Services, so the Sectigo cross-signs
# do not chain to a trusted root there.
# On Windows: adds the eight compromised intermediates and their eight Sectigo
# cross-signs (impacted-intermediates.json) to Cert:\LocalMachine\Disallowed so
# schannel-based clients (git, gh, PowerShell/.NET) reject chains through them,
# including chains to AAA Certificate Services, which Windows still trusts.
#
# This action is a no-op on self-hosted runners (runner.environment != 'github-hosted')
# because self-hosted runner operators own and update their own host images.
#
# Removal criteria and implementation notes: security/emudhra-plan2.md
# Tracked by: actions/runner-images#15018

name: Harden OS certificate trust (eMudhra/emSign CA stopgap)
description: >
Temporary per-job stopgap for CVE-2026-111943. Upgrades ca-certificates on
GitHub-hosted Linux runners and adds the compromised eMudhra intermediates
and cross-signs to the Disallowed store on GitHub-hosted Windows runners.
No-op on self-hosted.

runs:
using: composite
steps:

# -------------------------------------------------------------------------
# Windows path: add the compromised intermediates to LocalMachine\Disallowed.
# Takes effect immediately for all schannel-based clients in subsequent steps
# (git, gh, PowerShell/.NET, WinHTTP). Must precede actions/checkout.
# -------------------------------------------------------------------------
- name: Distrust compromised eMudhra intermediates (Windows, GitHub-hosted)
if: ${{ runner.os == 'Windows' && runner.environment == 'github-hosted' }}
shell: pwsh
env:
ACTION_PATH: ${{ github.action_path }}
run: |
$ErrorActionPreference = 'Stop'
. (Join-Path $env:ACTION_PATH 'emudhra_intermediates.ps1')

$certs = @(Get-EmudhraIntermediates)
try {
# Adding is idempotent, so no early exit when the image is already fixed.
$store = [System.Security.Cryptography.X509Certificates.X509Store]::new('Disallowed', 'LocalMachine')
$store.Open('ReadWrite')
try {
foreach ($cert in $certs) { $store.Add($cert) }
} finally {
$store.Close()
}

$notDistrusted = @($certs | Where-Object { -not (Test-ExplicitlyDistrusted $_) } | ForEach-Object { $_.EmudhraLabel })
if ($notDistrusted.Count -gt 0) {
Write-Error "Not explicitly distrusted after adding to LocalMachine\Disallowed: $($notDistrusted -join '; ')"
exit 1
}
Write-Host "Distrusted $($certs.Count) compromised eMudhra intermediates and cross-signs."
} finally {
$certs | ForEach-Object { $_.Dispose() }
}

# -------------------------------------------------------------------------
# Linux path: upgrade ca-certificates via apt.
# The Ubuntu archive GPG check does not depend on the trust store being
# fixed, so this bootstrap is safe. Retries guard against transient mirror
# failures. Must precede actions/checkout.
# -------------------------------------------------------------------------
- name: Upgrade ca-certificates (Linux, GitHub-hosted)
if: ${{ runner.os == 'Linux' && runner.environment == 'github-hosted' }}
shell: bash
run: |
set -euo pipefail
EMSIGN_G3_FP="86:A1:EC:BA:08:9C:4A:8D:3B:BE:27:34:C6:12:BA:34:1D:81:3E:04:3C:F9:E8:A8:62:CD:5C:57:A3:6B:BE:6B"
CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt"
VERSION_ID="$(. /etc/os-release && echo "${VERSION_ID}")"

# Fail closed if openssl is missing or the bundle cannot be parsed
# (mirrors ci/tools/runner_trust_probe.sh).
if ! command -v openssl >/dev/null 2>&1; then
echo "::error::openssl not found; cannot verify CA bundle"
exit 1
fi
if [[ ! -f "${CA_BUNDLE}" ]]; then
echo "::error::CA bundle not found: ${CA_BUNDLE}"
exit 1
fi

fingerprints_in_bundle() {
awk '/-----BEGIN CERTIFICATE-----/{cert=""} {cert=cert $0 "\n"}
/-----END CERTIFICATE-----/{print cert | "openssl x509 -noout -fingerprint -sha256 2>/dev/null"; close("openssl x509 -noout -fingerprint -sha256 2>/dev/null")}' \
"${CA_BUNDLE}"
}

fps="$(fingerprints_in_bundle)"
cert_count="$(grep -c . <<< "${fps}" || true)"
if (( cert_count == 0 )); then
echo "::error::No certificates parsed from ${CA_BUNDLE}; openssl may have failed"
exit 1
fi

if grep -qiF "${EMSIGN_G3_FP}" <<< "${fps}"; then
before_state="not fixed"
else
before_state="already fixed"
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
before_ver="$(dpkg-query -W -f='${Version}' ca-certificates 2>/dev/null || echo unknown)"
echo "::notice::emSign ECC Root CA - G3 on ${VERSION_ID}: ${before_state} (ca-certificates ${before_ver})"

if [ "${before_state}" = "already fixed" ]; then
exit 0
fi

for attempt in 1 2 3; do
if sudo apt-get update -q && sudo apt-get install --only-upgrade -y ca-certificates; then
break
fi
if [ "${attempt}" -lt 3 ]; then
echo "apt-get attempt ${attempt} failed; retrying in 10s..."
sleep 10
else
echo "::error::apt-get upgrade failed after ${attempt} attempts"
exit 1
fi
done

after_ver="$(dpkg-query -W -f='${Version}' ca-certificates 2>/dev/null || echo unknown)"
after_fps="$(fingerprints_in_bundle)"
after_count="$(grep -c . <<< "${after_fps}" || true)"
if (( after_count == 0 )); then
echo "::error::No certificates parsed after upgrade; cannot verify fix"
exit 1
fi
if grep -qiF "${EMSIGN_G3_FP}" <<< "${after_fps}"; then
echo "::error::emSign ECC Root CA - G3 still present after upgrade (ca-certificates ${after_ver} on ${VERSION_ID})"
exit 1
fi
echo "ca-certificates upgraded to ${after_ver}; emSign ECC Root CA - G3 absent."
50 changes: 50 additions & 0 deletions .github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
#
# SPDX-License-Identifier: Apache-2.0
#
# Helpers for the compromised eMudhra intermediates and their Sectigo
# cross-signs listed in impacted-intermediates.json. Dot-sourced by this
# action's Windows step and by ci/tools/runner_trust_probe.ps1.
#
# impacted-intermediates.json is the disclosure's file, unmodified (SHA-256
# f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e). The set
# is closed: new certificates for these keys would have to be issued by
# another CA, and all existing ones are revoked.

$EmudhraIntermediatesPath = Join-Path $PSScriptRoot 'impacted-intermediates.json'
$EmudhraIntermediatesCount = 16

# Returns the certificates as X509Certificate2 objects; callers dispose them.
function Get-EmudhraIntermediates {
$entries = @(Get-Content -LiteralPath $EmudhraIntermediatesPath -Raw | ConvertFrom-Json)
if ($entries.Count -ne $EmudhraIntermediatesCount) {
throw "Expected $EmudhraIntermediatesCount certificates in $EmudhraIntermediatesPath, found $($entries.Count)."
}
foreach ($entry in $entries) {
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPem($entry.pem)
if ($cert.GetCertHashString('SHA256') -ne $entry.sha256.ToUpperInvariant()) {
$cert.Dispose()
throw "SHA-256 mismatch for $($entry.name) (issued by $($entry.issued_by))."
}
Add-Member -InputObject $cert -NotePropertyName EmudhraLabel -NotePropertyValue "$($entry.name) (issued by $($entry.issued_by))"
$cert
}
}

# True if Windows chain building reports the certificate as explicitly
# distrusted. This covers both a local LocalMachine\Disallowed entry and
# Microsoft's automatically updated disallowed list. Revocation checking and
# AIA downloads are off so the result reflects the local trust state only.
function Test-ExplicitlyDistrusted([System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert) {
$explicitDistrust = [System.Security.Cryptography.X509Certificates.X509ChainStatusFlags]::ExplicitDistrust
$chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new()
try {
$chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck
$chain.ChainPolicy.DisableCertificateDownloads = $true
[void]$chain.Build($Cert)
$statuses = @($chain.ChainStatus) + @($chain.ChainElements | ForEach-Object { $_.ChainElementStatus })
return [bool]($statuses | Where-Object { $_.Status.HasFlag($explicitDistrust) })
} finally {
$chain.Dispose()
}
}
Loading
Loading