|
| 1 | +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: Apache-2.0 |
| 4 | +# |
| 5 | +# Temporary stopgap for the eMudhra/emSign CA key compromise (CVE-2026-111943) |
| 6 | +# on GitHub-hosted runners until GitHub ships fixed images. |
| 7 | +# |
| 8 | +# 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. 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. |
| 16 | +# |
| 17 | +# This action is a no-op on self-hosted runners (runner.environment != 'github-hosted') |
| 18 | +# because self-hosted runner operators own and update their own host images. |
| 19 | +# |
| 20 | +# Removal criteria and implementation notes: security/emudhra-plan2.md |
| 21 | +# Tracked by: actions/runner-images#15018 |
| 22 | + |
| 23 | +name: Harden OS certificate trust (eMudhra/emSign CA stopgap) |
| 24 | +description: > |
| 25 | + Temporary per-job stopgap for CVE-2026-111943. Upgrades ca-certificates on |
| 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. |
| 29 | +
|
| 30 | +runs: |
| 31 | + using: composite |
| 32 | + steps: |
| 33 | + |
| 34 | + # ------------------------------------------------------------------------- |
| 35 | + # Windows path: add the compromised intermediates to LocalMachine\Disallowed. |
| 36 | + # Takes effect immediately for all schannel-based clients in subsequent steps |
| 37 | + # (git, gh, PowerShell/.NET, WinHTTP). Must precede actions/checkout. |
| 38 | + # ------------------------------------------------------------------------- |
| 39 | + - name: Distrust compromised eMudhra intermediates (Windows, GitHub-hosted) |
| 40 | + if: ${{ runner.os == 'Windows' && runner.environment == 'github-hosted' }} |
| 41 | + shell: pwsh |
| 42 | + env: |
| 43 | + ACTION_PATH: ${{ github.action_path }} |
| 44 | + run: | |
| 45 | + $ErrorActionPreference = 'Stop' |
| 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() } |
| 67 | + } |
| 68 | +
|
| 69 | + # ------------------------------------------------------------------------- |
| 70 | + # Linux path: upgrade ca-certificates via apt. |
| 71 | + # The Ubuntu archive GPG check does not depend on the trust store being |
| 72 | + # fixed, so this bootstrap is safe. Retries guard against transient mirror |
| 73 | + # failures. Must precede actions/checkout. |
| 74 | + # ------------------------------------------------------------------------- |
| 75 | + - name: Upgrade ca-certificates (Linux, GitHub-hosted) |
| 76 | + if: ${{ runner.os == 'Linux' && runner.environment == 'github-hosted' }} |
| 77 | + shell: bash |
| 78 | + run: | |
| 79 | + set -euo pipefail |
| 80 | + 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" |
| 81 | + CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt" |
| 82 | + VERSION_ID="$(. /etc/os-release && echo "${VERSION_ID}")" |
| 83 | +
|
| 84 | + # Fail closed if openssl is missing or the bundle cannot be parsed |
| 85 | + # (mirrors ci/tools/runner_trust_probe.sh). |
| 86 | + if ! command -v openssl >/dev/null 2>&1; then |
| 87 | + echo "::error::openssl not found; cannot verify CA bundle" |
| 88 | + exit 1 |
| 89 | + fi |
| 90 | + if [[ ! -f "${CA_BUNDLE}" ]]; then |
| 91 | + echo "::error::CA bundle not found: ${CA_BUNDLE}" |
| 92 | + exit 1 |
| 93 | + fi |
| 94 | +
|
| 95 | + fingerprints_in_bundle() { |
| 96 | + awk '/-----BEGIN CERTIFICATE-----/{cert=""} {cert=cert $0 "\n"} |
| 97 | + /-----END CERTIFICATE-----/{print cert | "openssl x509 -noout -fingerprint -sha256 2>/dev/null"; close("openssl x509 -noout -fingerprint -sha256 2>/dev/null")}' \ |
| 98 | + "${CA_BUNDLE}" |
| 99 | + } |
| 100 | +
|
| 101 | + fps="$(fingerprints_in_bundle)" |
| 102 | + cert_count="$(grep -c . <<< "${fps}" || true)" |
| 103 | + if (( cert_count == 0 )); then |
| 104 | + echo "::error::No certificates parsed from ${CA_BUNDLE}; openssl may have failed" |
| 105 | + exit 1 |
| 106 | + fi |
| 107 | +
|
| 108 | + if grep -qiF "${EMSIGN_G3_FP}" <<< "${fps}"; then |
| 109 | + before_state="not fixed" |
| 110 | + else |
| 111 | + before_state="already fixed" |
| 112 | + fi |
| 113 | + before_ver="$(dpkg-query -W -f='${Version}' ca-certificates 2>/dev/null || echo unknown)" |
| 114 | + echo "::notice::emSign ECC Root CA - G3 on ${VERSION_ID}: ${before_state} (ca-certificates ${before_ver})" |
| 115 | +
|
| 116 | + if [ "${before_state}" = "already fixed" ]; then |
| 117 | + exit 0 |
| 118 | + fi |
| 119 | +
|
| 120 | + for attempt in 1 2 3; do |
| 121 | + if sudo apt-get update -q && sudo apt-get install --only-upgrade -y ca-certificates; then |
| 122 | + break |
| 123 | + fi |
| 124 | + if [ "${attempt}" -lt 3 ]; then |
| 125 | + echo "apt-get attempt ${attempt} failed; retrying in 10s..." |
| 126 | + sleep 10 |
| 127 | + else |
| 128 | + echo "::error::apt-get upgrade failed after ${attempt} attempts" |
| 129 | + exit 1 |
| 130 | + fi |
| 131 | + done |
| 132 | +
|
| 133 | + after_ver="$(dpkg-query -W -f='${Version}' ca-certificates 2>/dev/null || echo unknown)" |
| 134 | + after_fps="$(fingerprints_in_bundle)" |
| 135 | + after_count="$(grep -c . <<< "${after_fps}" || true)" |
| 136 | + if (( after_count == 0 )); then |
| 137 | + echo "::error::No certificates parsed after upgrade; cannot verify fix" |
| 138 | + exit 1 |
| 139 | + fi |
| 140 | + if grep -qiF "${EMSIGN_G3_FP}" <<< "${after_fps}"; then |
| 141 | + echo "::error::emSign ECC Root CA - G3 still present after upgrade (ca-certificates ${after_ver} on ${VERSION_ID})" |
| 142 | + exit 1 |
| 143 | + fi |
| 144 | + echo "ca-certificates upgraded to ${after_ver}; emSign ECC Root CA - G3 absent." |
0 commit comments