#!/bin/sh # eMudhra intermediate CA compromise: developer self-check (macOS and Linux) # Background: https://emudhra.we-have-a-problem.com/ # Fix status last reviewed: 2026-10-20 # # Usage: sh emudhra-check.sh [project-dir] # sh emudhra-check.sh --repos # every repo/project under root-dir # or: curl -fsSL https://gist.githubusercontent.com/horsefacts/bcdaba798b6576e49d3ae8623afbca84/raw/emudhra-check.sh | sh -s -- [same arguments] # # 1. Connects to the public test hosts with each TLS client found on this machine # (Apple's TLS stack, OpenSSL/LibreSSL, curl, Python, certifi, Nix, Node.js, Bun). # 2. Checks the CA bundles your tools actually use: SSL_CERT_FILE-style overrides and # Nix bundles (NIX_SSL_CERT_FILE, ssl-cert-file, profiles, nix-darwin, Determinate), # which macOS and distro fixes do not reach. Then checks tool versions. # 3. Finds global tools that ship their own root store and still contain the # affected root without the blocklist fix: binaries on PATH, cargo/go/bun/deno # bins, global npm packages, other Node.js installs, pipx/uv tool environments, # Homebrew, and Electron apps in /Applications. # 4. Scans lockfiles in project-dir (default: .), or with --repos, in every project # under root-dir (recursively, skipping node_modules, .git, target, venvs, vendor). # Read-only: it changes nothing. Exit status: 0 nothing found, 1 something needs fixing, # 3 some checks could not complete (the machine is not cleared). # Not covered: containers, VMs, Java keystores and JARs, and apps that pin certificates. # A binary the scan does not flag is not proven safe. # The whole script is one block so that `curl | sh` parses all of it before running # anything: a truncated download does not run, and no command can read the rest of # the script from stdin. { [ -n "${ZSH_VERSION:-}" ] && emulate sh # `curl | zsh` would otherwise not split words SITE=https://emudhra.we-have-a-problem.com/ CONTROL=emudhra.we-have-a-problem.com # The 12 test hosts: each of the 6 compromised TLS intermediates, via the emSign root and # via the Sectigo (AAA) cross-sign. BAD_HOSTS= for k in dv ov ev; do for g in g3b g3c; do BAD_HOSTS="$BAD_HOSTS $k-$g-emudhra.we-have-a-problem.com $k-$g-aaa-emudhra.we-have-a-problem.com" done; done REPOS= case ${1:-} in --repos) REPOS=1; DIR=${2:-.} ;; -h|--help) echo "Usage: sh emudhra-check.sh [project-dir | --repos root-dir]"; exit 0 ;; *) DIR=${1:-.} ;; esac [ "$DIR" = / ] || DIR=${DIR%/} [ -d "$DIR" ] || { echo "Not a directory: $DIR" >&2; exit 2; } OS=$(uname -s) fails=0 warns=0 skips=0 if [ -t 1 ]; then R=$(printf '\033[31m') G=$(printf '\033[32m') Y=$(printf '\033[33m') N=$(printf '\033[0m'); fi ok() { [ -n "${QUIET:-}" ] || printf '%s[ OK ]%s %s\n' "$G" "$N" "$1"; } skip() { printf '%s[SKIP]%s %s\n' "$Y" "$N" "$1"; skips=$((skips + 1)); } fail() { printf '%s[FAIL]%s %s\n' "$R" "$N" "$1"; shift; for l in "$@"; do printf ' -> %s\n' "$l"; done; fails=$((fails + 1)); } warn() { printf '%s[WARN]%s %s\n' "$Y" "$N" "$1"; shift; for l in "$@"; do printf ' -> %s\n' "$l"; done; warns=$((warns + 1)); } have() { command -v "$1" >/dev/null 2>&1; } # tmo CMD...: run CMD with a 20 second deadline (macOS has no `timeout`; perl does). tmo() { if have timeout; then timeout 20 "$@" elif have gtimeout; then gtimeout 20 "$@" elif have perl; then perl -e 'alarm shift; exec @ARGV' 20 "$@" else "$@"; fi } # ver_lt A B: true if version A < B, comparing numeric fields only. ver_lt() { awk -v a="$1" -v b="$2" 'BEGIN { n = split(a, x, /[^0-9]+/); m = split(b, y, /[^0-9]+/) for (i = 1; i <= (n > m ? n : m); i++) { if (x[i] + 0 < y[i] + 0) exit 0; if (x[i] + 0 > y[i] + 0) exit 1 } exit 1 }' } # Byte patterns for finding embedded root stores. # root: public keys of the roots in the disclosure's roots.json (emSign ECC Root CA - G3, # AAA Certificate Services, and the direct issuers emSign Root TLS CA - G3 and # emSign Root CS CA - G3), as base64 fragments (PEM bundles, any line layout) and # as raw bytes (DER, webpki-roots). # fixed: the blocklist added by patched builds (Go: hex; Node/Electron: raw SHA-256; # rustls-webpki: raw SPKI) for the EM DV TLS CA - G3B intermediate. TMP=$(mktemp -d) || exit 2 trap 'rm -rf "$TMP"' EXIT printf 'jpQy4LRL1KPOxst3iAhK\nnjlfSU2fySU0WXTsuwYc\n8Byr+iuL+FBVIcUqEqy6\n\043\245\014\270\055\022\365\050\363\261\262\335\342\002\022\200\236\071\137\111\115\237\311\045\064\131\164\354\273\006\034\347\n' > "$TMP/root" printf 'S1UY8y2bmhGC1Pqy0wkw\nxyTurxFa70VJoSCsN6sj\ng4tqJVfMiWPPe3M/vg4a\n\320\305\160\042\315\111\055\124\143\314\266\156\150\106\013\123\352\313\114\044\300\274\162\116\352\361\025\256\364\124\232\022\n' >> "$TMP/root" printf '1cWxL6D9bMGH6CQ4u+gA\n6GN9s+PwQx9Bl4hC+0iU\nQOk8mWO/RwXQy10DCSxT\n\004\137\017\334\077\253\127\026\304\276\203\365\263\006\037\240\220\342\357\240\001\136\206\067\333\076\077\004\061\364\031\170\n' >> "$TMP/root" printf 'D0VA/Og76dz5Xzw1hnGQ\nfSR9993/qBkkvI48pXQV\nOdyHCgAZThgWejQ8OpFR\n\004\112\137\144\063\220\364\124\017\316\203\276\235\317\225\363\303\130\147\031\016\037\111\037\175\367\177\352\006\111\057\043\n' >> "$TMP/root" printf 'ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105\nEBED6475C2C2D80FCB199F3FE8DD6E6B3CE60B77EB15D4E42C3DEC0C6F5B5105\n\353\355\144\165\302\302\330\017\313\031\237\077\350\335\156\153\074\346\013\167\353\025\324\344\054\075\354\014\157\133\121\005\n\347\236\331\300\112\227\163\304\163\136\121\021\035\250\132\302\275\136\144\253\067\113\200\341\054\274\040\070\354\036\117\016\n' > "$TMP/fixed" bundles_root() { LC_ALL=C grep -qsF -f "$TMP/root" "$1"; } has_blocklist() { LC_ALL=C grep -qsF -f "$TMP/fixed" "$1"; } # trusts_root FILE: true if FILE trusts the affected root. In PEM bundles only plain # CERTIFICATE blocks count: Nix/OpenSSL bundles keep distrusted roots as TRUSTED # CERTIFICATE blocks with reject rules. pem_trusts_root() { if [ -d "$1" ]; then cat "$1"/* 2>/dev/null; else cat "$1" 2>/dev/null; fi | awk '/^-----BEGIN CERTIFICATE-----/ { p = 1 } p; /^-----END CERTIFICATE-----/ { p = 0 }' | LC_ALL=C grep -qF -f "$TMP/root" } trusts_root() { case $1 in *.pem|*.crt) pem_trusts_root "$1" ;; *) bundles_root "$1" && ! has_blocklist "$1" ;; esac } # nixish PATH: true if PATH is managed by Nix. nixish() { case $1 in /nix/*|*/.nix-profile/*|*/.local/state/nix/*|/run/current-system/*|/etc/profiles/per-user/*|/etc/static/*|/etc/nix/*) return 0 ;; esac [ "$OS" = Darwin ] && [ "$1" = /etc/ssl/certs/ca-certificates.crt ] # written by nix-darwin } # live NAME FIX...: run probe() against a known-good host, then all compromised hosts. # probe HOST exits 0 if the client trusted the certificate, 3 if it rejected the # certificate, and anything else if it could not tell (DNS, timeout, proxy, crash). # Only certificate rejections count as passing. live() { name=$1; shift probe "$CONTROL" >/dev/null 2>&1 if [ $? -ne 0 ]; then skip "$name: could not verify a known-good host ($CONTROL), so it was not tested (network, proxy, or CA problem?)"; result=skip; return fi for h in $BAD_HOSTS; do ( probe "$h" >/dev/null 2>&1; echo $? > "$TMP/rc.$h" ) & done wait bad= unknown= nbad=0 for h in $BAD_HOSTS; do case $(cat "$TMP/rc.$h" 2>/dev/null) in 0) bad="$bad ${h%%-emudhra*}"; nbad=$((nbad + 1)) ;; 3) ;; *) unknown="$unknown ${h%%-emudhra*}" ;; esac done if [ -n "$bad" ]; then if [ "$nbad" -eq 12 ]; then fail "$name TRUSTS all 12 compromised chains" "$@" else fail "$name TRUSTS $nbad of 12 compromised chains ($(echo $bad | sed 's/ /, /g'))" "$@"; fi result=fail nixish "$CA" && NIX_FAILED=1 elif [ "$(echo $unknown | wc -w)" -eq 12 ]; then skip "$name: could not reach any compromised test host (network or proxy blocking them?), so it was not tested"; result=skip elif [ -n "$unknown" ]; then skip "$name: no certificate error for$unknown (network errors or timeouts), so rejection is unconfirmed"; result=skip else ok "$name rejects all 12 compromised chains"; result=ok; fi } BREW=$(brew --prefix 2>/dev/null) MAC_CERTPEM= NIX_FAILED= CA= [ "$OS" = Darwin ] && trusts_root /etc/ssl/cert.pem && MAC_CERTPEM=1 NIX_DARWIN=; [ "$OS" = Darwin ] && [ -e /run/current-system ] && NIX_DARWIN=1 BLACKLIST='[ "emSign ECC Root CA - G3" "Comodo AAA Services root" ]' # ca_for BIN DEFAULT: the CA file a binary uses. Nix builds of OpenSSL read NIX_SSL_CERT_FILE first. ca_for() { if nixish "$1" && [ -n "$NIX_SSL_CERT_FILE" ]; then echo "$NIX_SSL_CERT_FILE" else echo "${SSL_CERT_FILE:-$2}"; fi } # nix_fix CAFILE: how to fix a Nix-managed CA bundle. nix_fix() { if [ "$1" = /etc/nix/macos-keychain.crt ]; then echo "Determinate Nix rebuilds this file from the macOS Keychain at startup, and no Determinate fix is known yet. Watch for one: sudo determinate-nixd upgrade" elif [ -e /etc/NIXOS ]; then echo "NixOS: the distrust is in nixos-unstable and nixos-26.05. Update and rebuild: sudo nixos-rebuild switch --upgrade (flakes: nix flake update nixpkgs), or set security.pki.caCertificateBlacklist = $BLACKLIST;" elif [ -n "$NIX_DARWIN" ]; then echo "nix-darwin: add security.pki.caCertificateBlacklist = $BLACKLIST; to your config, then darwin-rebuild switch" elif [ "$OS" = Darwin ]; then echo "No nixpkgs channel has the fixed cacert (3.131.1) yet. Point Nix at a fixed bundle: see 'Nix on macOS' at the end" else echo "No nixpkgs channel has the fixed cacert (3.131.1) yet. Fix your OS store, then: export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt (or use cacert.override { blacklist = $BLACKLIST; })" fi } DEB_WORKAROUND="sudo sed -i -e 's|^mozilla/emSign_ECC_Root_CA_-_G3.crt|!&|' -e 's|^mozilla/Comodo_AAA_Services_root.crt|!&|' /etc/ca-certificates.conf && sudo update-ca-certificates" os_fix() { case $OS in Darwin) if [ -n "$MAC_CERTPEM" ]; then echo "Remove the affected roots from /etc/ssl/cert.pem with the command printed at the end" else echo "Update macOS and Homebrew packages; see $SITE"; fi ;; FreeBSD) echo "pkg upgrade ca_root_nss (fixed in 3.131.1)" ;; Linux) ID=; [ -r /etc/os-release ] && . /etc/os-release case $ID in ubuntu) case $VERSION_ID in 1*|20.*|21.*) echo "Ubuntu $VERSION_ID has no fix yet (fixed in 22.04+). Workaround: $DEB_WORKAROUND" ;; *) echo "sudo apt-get update && sudo apt-get install --only-upgrade ca-certificates (fixed in 20260816ubuntu0.$VERSION_ID.1)" ;; esac ;; debian) echo "Debian has fixed this only in sid (20260816+nmu1); a trixie update (20250419+deb13u1) is queued and bookworm has none. Workaround: $DEB_WORKAROUND" ;; fedora|rhel|centos|rocky|almalinux|amzn) echo "No package fix yet (Fedora: bugzilla 2556376). Apply the 'Fedora, RHEL, CentOS Stream, Rocky Linux, AlmaLinux and Amazon Linux' workaround at $SITE" ;; arch) echo "sudo pacman -Syu (ca-certificates-mozilla fixed in 3.131.1-1)" ;; alpine) echo "apk upgrade ca-certificates (fixed in 20261020-r0 on 3.22+; on older branches apply the Alpine workaround at $SITE)" ;; gentoo) echo "emerge -u app-misc/ca-certificates (fixed in 20260816.3.125)" ;; wolfi|chainguard) echo "apk upgrade ca-certificates (fixed in 20260909-r3)" ;; nixos) nix_fix /etc/ssl/certs/ca-certificates.crt ;; *) echo "No ca-certificates package fix is known for '${ID:-this distro}' yet. Apply the workaround for your distro at $SITE" ;; esac ;; *) echo "Update your OS trust store; see $SITE" ;; esac } # store_fix CAFILE: how to fix the CA bundle a client uses. store_fix() { if nixish "$1"; then nix_fix "$1"; return; fi case $1 in */certifi/*) echo "Upgrade certifi in that Python environment: python3 -m pip install -U certifi (fixed in 2026.10.19)" ;; */Python.framework/*) echo "python.org Python: run 'Install Certificates.command' in /Applications/Python 3.x/ to install current certifi" ;; *homebrew*|*linuxbrew*|/usr/local/etc/*|/usr/local/opt/*|/usr/local/Cellar/*) echo "brew update && brew upgrade ca-certificates (fixed in 2026-09-25_1)" ;; *) os_fix ;; esac } # electron_check LABEL VERSION FIX electron_check() { case $2 in 44.*) need=44.9.1 ;; 43.*) need=43.8.1 ;; 42.*) need=42.11.16 ;; 4[5-9].*|[5-9][0-9].*) need=0 ;; *) need=999 ;; # no fix for Electron 41 and older esac if ver_lt "$2" $need; then fail "$1 uses Electron $2, which is not patched" "$3" else ok "$1 (Electron $2)"; fi } # On macOS, /usr/bin/python3 is a stub that pops up an installer without the Command Line Tools. PY=python3 have python3 || PY= if [ "$OS" = Darwin ] && [ "$(command -v python3)" = /usr/bin/python3 ] && ! xcode-select -p >/dev/null 2>&1; then PY=; fi echo "eMudhra compromised-intermediate check ($SITE)" echo "The compromised keys are trivially recoverable and have been published: treat every FAIL as urgent." echo echo "== TLS clients (live test) ==" APPLE= if [ "$OS" = Darwin ] && have osascript; then # NSURLError -1200..-1206 are TLS/certificate failures; anything else is unknown. probe() { tmo osascript -l JavaScript -e 'ObjC.import("Foundation"); ObjC.import("stdlib") function run(argv) { try { var err = Ref() var d = $.NSData.dataWithContentsOfURLOptionsError($.NSURL.URLWithString("https://" + argv[0] + "/"), 0, err) if (!d.isNil()) $.exit(0) var e = err[0], u = e.userInfo.objectForKey("NSUnderlyingError") var c = u.isNil() ? e.code : u.code $.exit(c <= -1200 && c >= -1206 ? 3 : 2) } catch (x) { $.exit(2) } }' "$1"; } live "macOS TLS (Safari, URLSession, Swift, Go on macOS, ...)" \ "Install the latest macOS update (System Settings > General > Software Update) and keep 'Install Security Responses and system files' on" APPLE=$result fi if have openssl; then # Judge by the printed verify result, not the exit code: LibreSSL exits 0 even when it # rejects the chain, and on macOS it exits non-zero after a good handshake because # poll() cannot watch /dev/null. Stdin is a pipe for the same reason. probe() { out=$(echo | tmo openssl s_client -connect "$1:443" -servername "$1" 2>&1) case $out in *"Verify return code: 0 (ok)"*) return 0 ;; *"Verify return code: "*|*"verify error:"*) return 3 ;; esac return 2 } d=$(openssl version -d | sed 's/^[^"]*"//; s/"$//') CA=$(ca_for "$(command -v openssl)" "$d/cert.pem") live "OpenSSL store ($(openssl version | cut -d' ' -f1-2), $CA)" "$(store_fix "$CA")" fi if have curl; then probe() { curl -s -o /dev/null --max-time 10 "https://$1/"; case $? in 0) return 0 ;; 60|51) return 3 ;; esac; return 2; } c=$(command -v curl) ca=$(curl -sv -o /dev/null --max-time 10 "https://$CONTROL/" 2>&1 | sed -n 's/.*CAfile: *//p' | head -n 1) CA=${ca:-$(ca_for "$c" "$c")} live "curl ($c${ca:+, $ca})" "$(store_fix "$CA")" fi if [ -n "$PY" ]; then probe() { python3 -c ' import sys, ssl, urllib.request, urllib.error ctx = ssl.create_default_context(cafile=sys.argv[2] if len(sys.argv) > 2 else None) CertError = getattr(ssl, "SSLCertVerificationError", ()) try: urllib.request.urlopen("https://" + sys.argv[1] + "/", timeout=10, context=ctx) except urllib.error.HTTPError: pass except urllib.error.URLError as e: sys.exit(3 if isinstance(e.reason, CertError) else 2) except CertError: sys.exit(3) except Exception: sys.exit(2)' "$1" ${CAFILE:+"$CAFILE"}; } CAFILE= CA=$(ca_for "$(command -v python3)" "$(python3 -c 'import ssl; print(ssl.get_default_verify_paths().openssl_cafile)' 2>/dev/null)") live "Python ssl/urllib ($(python3 -V 2>&1), $(command -v python3))" "$(store_fix "$CA")" CA= if CAFILE=$(python3 -c 'import certifi; print(certifi.where())' 2>/dev/null); then v=$(python3 -c 'import certifi; print(certifi.__version__)') live "Python certifi $v (requests, httpx, ...)" \ "python3 -m pip install -U 'certifi>=2026.10.19' (also update certifi pins in lockfiles)" fi fi if have nix-instantiate; then STAMP=$$$(date +%s) # unique URL, so Nix's download cache cannot answer probe() { out=$(tmo nix-instantiate --eval -E "builtins.fetchurl \"https://$1/?emudhra-check=$STAMP\"" \ --option tarball-ttl 0 --option connect-timeout 10 2>&1) && return 0 case $out in *"remote key was not OK (60)"*) return 3 ;; esac # curl's certificate error return 2 } CA=$(nix --extra-experimental-features nix-command config show ssl-cert-file 2>/dev/null) live "Nix fetcher (flake inputs, builtins.fetchurl${CA:+; ssl-cert-file=$CA})" "$(store_fix "${CA:-/nix}")" fi CA= NODE=$(command -v node) if [ -n "$NODE" ]; then probe() { node -e ' process.on("uncaughtException", () => process.exit(2)) const cert = (e) => /CERT|ISSUER|SIGNATURE|VERIFY|SELF_SIGNED/.test(String(e.code)) require("https").get("https://" + process.argv[1] + "/", { timeout: 10000 }, () => process.exit(0)) .on("error", (e) => process.exit(cert(e) ? 3 : 2)).on("timeout", function () { this.destroy() })' "$1"; } live "Node.js $(node --version) ($NODE${NODE_OPTIONS:+, NODE_OPTIONS=$NODE_OPTIONS}${NODE_EXTRA_CA_CERTS:+, NODE_EXTRA_CA_CERTS=$NODE_EXTRA_CA_CERTS})" \ "The fix (nodejs/node#66871) merged on 2026-10-20 but no Node.js release includes it yet. Upgrade as soon as one ships." \ "Until then, see the stopgap in the comments on https://github.com/nodejs/node/pull/66871" fi BUN=$(command -v bun) if [ -n "$BUN" ]; then probe() { bun -e ' try { await fetch("https://" + process.argv[1] + "/", { signal: AbortSignal.timeout(10000) }); process.exit(0) } catch (e) { process.exit(/CERT|ISSUER|SIGNATURE|VERIFY|SELF_SIGNED/.test(String(e.code)) ? 3 : 2) }' "$1"; } live "Bun $(bun --version) ($BUN)" \ "bun upgrade --canary removes the root from Bun's own bundle (oven-sh/bun#46159; stable 1.4.3 predates it)" \ "Bun also trusts the OS store, so fix that too: $(os_fix)" fi echo echo "== CA bundles (environment overrides and Nix) ==" # Tools that honor these variables ignore the OS store, so OS fixes do not reach them. { for v in NIX_SSL_CERT_FILE SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO AWS_CA_BUNDLE PIP_CERT; do eval "f=\${$v:-}"; [ -n "$f" ] && echo "$f \$$v" done have nix && f=$(nix --extra-experimental-features nix-command config show ssl-cert-file 2>/dev/null) && [ -n "$f" ] && echo "$f Nix ssl-cert-file" if have git && f=$(git config --get http.sslCAInfo 2>/dev/null) && [ -n "$f" ]; then case $f in "~/"*) f="$HOME/${f#??}" ;; esac echo "$f git http.sslCAInfo" fi have npm && f=$(npm config get cafile 2>/dev/null) && case $f in ""|null|undefined) ;; *) echo "$f npm cafile" ;; esac [ -n "${SSL_CERT_DIR:-}" ] && echo "$SSL_CERT_DIR" | tr ':' '\n' | while IFS= read -r d; do [ -n "$d" ] && echo "$d \$SSL_CERT_DIR"; done for f in /nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt "$HOME/.nix-profile/etc/ssl/certs/ca-bundle.crt" \ "/etc/profiles/per-user/$USER/etc/ssl/certs/ca-bundle.crt" /run/current-system/sw/etc/ssl/certs/ca-bundle.crt /etc/nix/macos-keychain.crt; do echo "$f Nix-managed" done [ -n "$NIX_DARWIN" ] && echo "/etc/ssl/certs/ca-certificates.crt nix-darwin" [ -e /etc/NIXOS ] && echo "/etc/ssl/certs/ca-certificates.crt NixOS" } > "$TMP/bundles" seen=" " nb= while IFS=' ' read -r f src; do [ -e "$f" ] || continue ino=$(ls -iLd "$f" | awk '{ print $1 }') case $seen in *" $ino "*) continue ;; esac seen="$seen$ino "; nb=1 if ! pem_trusts_root "$f"; then ok "$f ($src)" elif [ "$src" = Nix-managed ]; then # Not referenced by this shell's environment or Nix config (those were listed first). warn "$f (Nix-managed, not used by this shell) trusts an affected root" \ "Nothing to do unless other shells or services use it; it is fixed once nixpkgs ships cacert 3.131.1 and you upgrade that profile" else fail "$f ($src) trusts an affected root" "$(store_fix "$f")" nixish "$f" && NIX_FAILED=1 fi done < "$TMP/bundles" [ -n "$nb" ] || echo "(no CA bundle overrides or Nix bundles found)" echo echo "== Tool versions ==" if have go; then v=$(go env GOVERSION 2>/dev/null); v=${v#go} case $v in 1.27.*) need=1.27.3 ;; *) need=1.26.10 ;; esac gofix="Upgrade to Go 1.27.3 or 1.26.10+, then rebuild and redeploy your Go binaries" if ! ver_lt "$v" "$need"; then ok "Go $v" elif [ "$APPLE" = ok ]; then warn "Go $v predates the fix (on macOS, Go uses Apple's verifier, which blocks these)" "$gofix" else fail "Go $v is not patched" "$gofix"; fi fi if [ -n "$PY" ] && v=$(python3 -m pip --version 2>/dev/null); then v=$(echo "$v" | cut -d' ' -f2) if ver_lt "$v" 26.2.2; then fail "pip $v is not patched" "python3 -m pip install -U pip (fixed in 26.2.2)" else ok "pip $v"; fi fi if [ -n "$BREW" ] && v=$(brew list --versions ca-certificates 2>/dev/null); then v=$(echo "$v" | cut -d' ' -f2) if ver_lt "$v" 2026-09-25_1; then fail "Homebrew ca-certificates $v is not patched" "brew update && brew upgrade ca-certificates (fixed in 2026-09-25_1)" else ok "Homebrew ca-certificates $v"; fi fi echo echo "== Global tools and apps ==" if [ "$OS" = Darwin ]; then for app in /Applications/*.app /Applications/*/*.app "$HOME"/Applications/*.app; do p="$app/Contents/Frameworks/Electron Framework.framework/Resources/Info.plist" [ -f "$p" ] || continue v=$(plutil -extract CFBundleVersion raw -o - "$p" 2>/dev/null) || continue electron_check "${app##*/}" "$v" "Update the app. Electron is fixed in 44.9.1, 43.8.1, 42.11.16 and 45+; if no update exists, ask the vendor" done fi # owner_hint FILE: how to update a tool that bundles the affected root. owner_hint() { if [ -e "$HOME/.cargo/bin/rustup" ] && [ "$1" -ef "$HOME/.cargo/bin/rustup" ]; then echo "This is rustup (cargo, rustc and friends in ~/.cargo/bin are rustup proxies). Its fix (rust-lang/rustup#5154) is merged but not released; 1.29.1 predates it. Run rustup self update once a release ships" return fi case $1 in */kolide*|*/osquery*) echo "Device-management agent (Kolide/osquery): report it to IT; the vendor has to ship the fix. Do not modify it yourself" ;; /nix/*|*/.nix-profile/*|*/.local/state/nix/*|/run/current-system/*|/etc/profiles/per-user/*) echo "Installed by Nix: update it once nixpkgs has a fixed version (nix profile upgrade, home-manager switch, darwin-rebuild switch or nixos-rebuild switch)" ;; */node) echo "Node.js: no release includes the fix yet (nodejs/node#66871). Upgrade or remove this install once one ships" ;; */bun|*/bunx) echo "bun upgrade --canary (or the first stable release after 1.4.3)" ;; */uv) echo "uv self update (or your package manager) (fixed in 0.13.4)" ;; */mise) echo "mise self-update once 2026.10.16 is released (jdx/mise#14781)" ;; */deno) echo "Deno's fix (denoland/deno#37046) is merged but not released; 2.9.7 predates it. Run deno upgrade once a release ships" ;; */.foundry/*) echo "Update Foundry with foundryup, and check that the new build no longer bundles the root (rerun this script)" ;; */pipx/*) echo "pipx reinstall-all (reinstalls tools with current certifi)" ;; */uv/tools/*|"$UVTOOLS"/*) echo "uv tool upgrade --all (upgrades certifi inside tool environments)" ;; */.cargo/bin/*) echo "Reinstall it with 'cargo install' to pick up webpki-roots 1.0.10 / rustls-webpki 0.103.16" ;; */node_modules/*) echo "Update the global npm package that ships it (npm update -g), or ask its maintainer" ;; "${BREW:-/nonexistent}"/*) echo "brew update && brew upgrade" ;; *) echo "Update this tool. It ships its own root store, so OS updates do not protect it; if there is no fixed release, ask the vendor" ;; esac } UVTOOLS=$(uv tool dir 2>/dev/null) PIPX=$(pipx environment --value PIPX_LOCAL_VENVS 2>/dev/null) # Folders whose subfolders are separately installed packages: reported per package. GROUPROOTS=$( { npm root -g 2>/dev/null; echo "$HOME/.bun/install/global/node_modules"; } | sed 's/^/package /' for d in .vscode .vscode-insiders .vscode-oss .vscode-server .cursor .windsurf; do echo "extension $HOME/$d/extensions"; done) { # Native binaries and CA bundles in global npm/bun packages and editor extensions # (first, so PATH symlinks to them dedupe). echo "$GROUPROOTS" | while IFS=' ' read -r kind r; do [ -d "$r" ] && find "$r" -type f \( -size +1000k -o -name cacert.pem \) 2>/dev/null done # Large executables on PATH and in common global bin dirs. { printf '%s\n' "$PATH" | tr ':' '\n' printf '%s\n' "$HOME/.cargo/bin" "$HOME/go/bin" "$HOME/.bun/bin" "$HOME/.deno/bin" "$HOME/.local/bin" "$HOME/.foundry/bin" } | awk 'NF && !seen[$0]++' | while IFS= read -r d; do [ -d "$d" ] && find -L "$d" -maxdepth 1 -type f -size +1000k 2>/dev/null done # Other Node.js installs (nvm, Volta, fnm, mise, asdf, Homebrew node@N). for f in "$HOME"/.nvm/versions/node/*/bin/node "$HOME"/.volta/tools/image/node/*/bin/node \ "$HOME"/.local/share/fnm/node-versions/*/installation/bin/node \ "$HOME/Library/Application Support/fnm/node-versions"/*/installation/bin/node \ "$HOME"/.local/share/mise/installs/node/*/bin/node "$HOME"/.asdf/installs/nodejs/*/bin/node \ "$BREW"/opt/node*/bin/node; do [ -f "$f" ] && echo "$f" done # CA bundles (certifi, botocore, pip's vendored copy, ...) in pipx and uv tool # environments, Homebrew formulae and the AWS CLI. for d in "$PIPX" "$HOME/.local/pipx" "$HOME/.local/share/pipx" "$HOME/Library/Application Support/pipx" \ "$UVTOOLS" "$HOME/.local/share/uv/tools" "${BREW:+$BREW/Cellar}" /usr/local/aws-cli; do [ -n "$d" ] && [ -d "$d" ] && find "$d" -name cacert.pem 2>/dev/null done # Tools that keep inactive versions or helpers outside PATH (Foundry, Claude Code, Codex), # and device-management agents (Kolide, osquery). for d in "$HOME/.foundry" "$HOME/.claude" "$HOME/.local/share/claude" "$HOME/.codex" \ /usr/local/kolide-k2 /usr/local/kolide /opt/osquery /Library/Kolide; do [ -d "$d" ] && find "$d" -type f \( -size +1000k -o -name cacert.pem -o -name 'certs.pem' \) 2>/dev/null done } | awk '!seen[$0]++' > "$TMP/files" # group_of FILE: "package NAME" or "extension NAME" if FILE is inside a GROUPROOTS folder. # (A function, because bash 3.2, macOS's /bin/sh, cannot parse `case` inside $(...).) group_of() { while IFS=' ' read -r kind r; do case $1 in "$r"/@*) p=${1#"$r"/}; echo "$kind $(echo "$p" | cut -d/ -f1-2)"; break ;; "$r"/*) p=${1#"$r"/}; echo "$kind ${p%%/*}"; break ;; esac done </dev/null | awk '{ print $1 }') case $seen in *" $ino "*) continue ;; esac seen="$seen$ino " trusts_root "$f" || continue real=$(readlink -f "$f" 2>/dev/null || realpath "$f" 2>/dev/null) || real=$f # Report a package or extension once, not once per platform binary it ships. grp=$(group_of "$real") if [ -n "$grp" ]; then case $seen in *" $grp "*) continue ;; esac seen="$seen$grp " if [ "${grp%% *}" = package ]; then fail "Global $grp ships files that bundle an affected root without the blocklist fix" \ "Update it: npm update -g ${grp#* } (or bun update -g), or ask its maintainer for a fixed release" else fail "Editor $grp ships files that bundle an affected root without the blocklist fix" \ "Update the extension in your editor, or remove it; if no fixed version exists, ask its publisher" fi else shown=$f; [ "$real" != "$f" ] && shown="$f -> $real" fail "$shown bundles an affected root without the blocklist fix" "$(owner_hint "$real")" fi done < "$TMP/files" echo # Versions of a package in a Cargo.lock / uv.lock / poetry.lock style file. toml_lock() { awk -v p="$2" '/^\[/ { pkg = ($0 == "[[package]]"); n = ""; next } pkg && /^name = / { gsub(/"/, "", $3); n = $3 } pkg && /^version = / && n == p { gsub(/"/, "", $3); print $3 }' "$1" } certifi_check() { if ver_lt "$2" 2026.10.19; then fail "$1: certifi $2" "Bump certifi to 2026.10.19+ and relock" else ok "$1: certifi $2"; fi } # check_lock FILE LABEL: check one lockfile, or a node_modules dir for Electron. check_lock() { case ${1##*/} in Cargo.lock) for crate in webpki-roots webpki-root-certs; do for v in $(toml_lock "$1" $crate); do case $v in 0.26.*) ver_lt "$v" 0.26.11 || continue ;; esac # 0.26.11+ re-exports 1.x if ver_lt "$v" 1.0.10; then fail "$2: $crate $v" "cargo update -p $crate@$v (needs 1.0.10+; versions before 1.x need a Cargo.toml bump)" else ok "$2: $crate $v"; fi done done for v in $(toml_lock "$1" rustls-webpki); do if ver_lt "$v" 0.103.16; then fail "$2: rustls-webpki $v" "cargo update -p rustls-webpki@$v (needs 0.103.16+; older lines need a rustls upgrade)" else ok "$2: rustls-webpki $v"; fi done ;; uv.lock|poetry.lock) for v in $(toml_lock "$1" certifi); do certifi_check "$2" "$v"; done ;; requirements*.txt) for v in $(sed -n 's/^certifi==\([0-9.]*\).*/\1/p' "$1"); do certifi_check "$2" "$v"; done ;; composer.lock) v=$(awk '/"name": "composer\/ca-bundle"/ { f = 1 } f && /"version"/ { gsub(/[",v ]/, ""); sub(/.*:/, ""); print; exit }' "$1") if [ -n "$v" ]; then case $v in 1.4.*) need=1.4.16 ;; *) need=1.6.0 ;; esac if ver_lt "$v" $need; then fail "$2: composer/ca-bundle $v" "composer update composer/ca-bundle (fixed in 1.6.0 and 1.4.16)" else ok "$2: composer/ca-bundle $v"; fi fi ;; mix.lock) v=$(sed -n 's/.*"castore": {:hex, :castore, "\([0-9.]*\)".*/\1/p' "$1") if [ -n "$v" ]; then if ver_lt "$v" 1.0.22; then fail "$2: castore $v" "mix deps.update castore (fixed in 1.0.22)" else ok "$2: castore $v"; fi fi ;; .venv|venv) for c in "$1"/lib/python*/site-packages/certifi/cacert.pem; do if pem_trusts_root "$c"; then fail "$2: installed certifi trusts an affected root" "$2/bin/python -m pip install -U 'certifi>=2026.10.19'" else ok "$2: installed certifi"; fi done ;; node_modules) v=$(sed -n 's/^ *"version": "\([^"]*\)".*/\1/p' "$1/electron/package.json" | head -n 1) electron_check "$2/electron" "$v" "Upgrade to Electron 44.9.1, 43.8.1, 42.11.16 or 45+ (older majors have no fix)" ;; esac } if [ -n "$REPOS" ]; then echo "== Project dependencies in every repo under $DIR ==" DEPTH= else echo "== Project dependencies in $DIR (4 levels deep) ==" DEPTH="-maxdepth 4" fi # $DEPTH is unquoted on purpose: empty, or two words. find "$DIR" $DEPTH -name .git -prune -print \ -o \( -name .venv -o -name venv \) -prune -print \ -o \( -name target -o -name .tox -o -name vendor -o -name .direnv \ -o -name Library -o -name .cache -o -name .Trash -o -name .cargo -o -name .rustup -o -name .npm \ -o -name .pnpm-store -o -name .nvm -o -name .pyenv -o -path '*/go/pkg/mod' \) -prune \ -o -name node_modules -prune -print \ -o -type f \( -name Cargo.lock -o -name uv.lock -o -name poetry.lock -o -name 'requirements*.txt' \ -o -name composer.lock -o -name mix.lock \) -print 2>/dev/null > "$TMP/found" nrepos=$(grep -c '/\.git$' "$TMP/found") grep -v '/\.git$' "$TMP/found" > "$TMP/locks" # In --repos mode, show only problems, then a summary. QUIET=$REPOS nlocks=0 before=$fails while IFS= read -r p; do case $p in */node_modules) [ -f "$p/electron/package.json" ] || continue ;; */.venv|*/venv) ls "$p"/lib/python*/site-packages/certifi/cacert.pem >/dev/null 2>&1 || continue ;; esac nlocks=$((nlocks + 1)) check_lock "$p" "${p#"$DIR"/}" done < "$TMP/locks" QUIET= if [ "$nlocks" -eq 0 ]; then echo "(no Cargo.lock, uv.lock, poetry.lock, requirements*.txt, composer.lock, mix.lock, virtualenv certifi, or node_modules/electron found)" else echo "Checked $nlocks lockfile(s) in $nrepos git repo(s): $((fails - before)) problem(s)." fi if [ -n "$MAC_CERTPEM" ]; then echo echo "== macOS: remove the affected roots from /etc/ssl/cert.pem ==" echo "/etc/ssl/cert.pem (used by /usr/bin/curl, LibreSSL and some Pythons) still contains them. Run:" echo cat <<'EOF' sudo cp /etc/ssl/cert.pem /etc/ssl/cert.pem.bak && awk '/^(###|===) /{skip=0} /^=== .*\/CN=(AAA Certificate Services|emSign ECC Root CA - G3)$/{skip=1} !skip' /etc/ssl/cert.pem.bak | sudo tee /etc/ssl/cert.pem > /dev/null EOF fi if [ -n "$NIX_FAILED" ] && [ "$OS" = Darwin ] && [ -z "$NIX_DARWIN" ]; then echo echo "== Nix on macOS: use a fixed bundle ==" echo "Nix tools ignore /etc/ssl/cert.pem; they read NIX_SSL_CERT_FILE (and SSL_CERT_FILE), which point at a" echo "Nix cacert bundle, and no nixpkgs channel ships the fixed cacert (3.131.1) yet. Either:" echo echo " 1. Fix /etc/ssl/cert.pem (above), then add this after the Nix lines in your shell profile:" echo " export NIX_SSL_CERT_FILE=/etc/ssl/cert.pem" echo " and change SSL_CERT_FILE too if you set it." echo " 2. With home-manager:" echo " home.sessionVariables.NIX_SSL_CERT_FILE =" echo " \"\${pkgs.cacert.override { blacklist = $BLACKLIST; }}/etc/ssl/certs/ca-bundle.crt\";" echo echo "Open a new shell and rerun this script to confirm." fi echo [ "$warns" -gt 0 ] && echo "${Y}$warns warning(s).${N}" if [ "$fails" -gt 0 ]; then echo "${R}$fails issue(s) found.${N} Fix the items marked FAIL above. Details: $SITE" exit 1 fi if [ "$skips" -gt 0 ]; then echo "${Y}No failures, but $skips check(s) could not complete, so this machine is not cleared.${N} Fix the SKIP items (usually network or proxy) and rerun." exit 3 fi echo "${G}No issues found by these checks.${N} Not covered: containers, VMs, Java keystores and JARs. Keep your browser and OS up to date." }