#!/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-22 # # Usage: curl -fsSL https://emudhra.tehq.dev | sh # installed tools # curl -fsSL https://emudhra.tehq.dev | sh -s -- --quick # skip the binary scan # curl -fsSL https://emudhra.tehq.dev | sh -s -- --deep # read every large file, not just executables (slow) # curl -fsSL https://emudhra.tehq.dev | sh -s -- # also check that project's lockfiles # curl -fsSL https://emudhra.tehq.dev | sh -s -- --repos # lockfiles of every repo under root-dir # or: sh emudhra-check.sh [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). # A failure here means the client can be attacked now. # 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, plus pip, Perl, Java and Go. # 3. Finds installed tools that ship their own root store and still contain the # affected root without the blocklist fix: executables on PATH, cargo/go/bun/deno # bins, global npm packages, other Node.js installs, pipx/uv tool environments, # Homebrew, editor extensions, AI-agent folders, and Electron apps in /Applications. # 4. Only when asked: lockfiles in project-dir, or in every repo under root-dir. # Read-only: it changes nothing. It runs at low priority and one run at a time. # Exit status: 0 nothing found, 1 something needs fixing, 3 some checks could not # complete (the machine is not cleared). Not covered: containers, VMs, JARs, Docker # images 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 14 test hosts: each of the 6 compromised TLS intermediates via the emSign root and via # the Sectigo (AAA) cross-sign, plus two "noct" hosts whose certificates were never logged to # Certificate Transparency (clients that accept those can be attacked without a public trace). # Each test host's page contains EXPECTED; anything else (a proxy's block page) is no answer. H= for k in dv ov ev; do for g in g3b g3c; do H="$H $k-$g-emudhra.we-have-a-problem.com $k-$g-aaa-emudhra.we-have-a-problem.com" done; done NOCT="dv-g3b-noct-emudhra.we-have-a-problem.com dv-g3b-aaa-noct-emudhra.we-have-a-problem.com" FIRST="$(echo $H | cut -d' ' -f1-2) $NOCT $(echo $H | cut -d' ' -f3-4)" REST=$(echo $H | cut -d' ' -f5-) BAD_HOSTS="$FIRST $REST" NHOSTS=14 EXPECTED="Your client successfully connected to the site." # test_host HOST: true for the compromised test hosts, false for the known-good control. test_host() { case $1 in *-emudhra.*) return 0 ;; esac; return 1; } REPOS= QUICK= DEEP= DIR= while [ $# -gt 0 ]; do case $1 in --quick) QUICK=1 ;; --deep) DEEP=1 ;; --repos) REPOS=1; DIR=${2:-}; [ $# -gt 1 ] && shift ;; -h|--help) echo "Usage: sh emudhra-check.sh [--quick | --deep] [project-dir | --repos root-dir]"; exit 0 ;; -*) echo "Unknown option: $1 (try -h)" >&2; exit 2 ;; *) DIR=$1 ;; esac shift done if [ -n "$QUICK" ] && [ -n "$DEEP" ]; then echo "--quick and --deep cannot be combined" >&2; exit 2; fi if [ -n "$DIR" ]; then [ "$DIR" = / ] || DIR=${DIR%/} [ -d "$DIR" ] || { echo "Not a directory: $DIR" >&2; exit 2; } elif [ -n "$REPOS" ]; then echo "--repos needs a directory" >&2; exit 2; fi OS=$(uname -s) fails=0 warns=0 skips=0 exposed= # Be gentle on a busy machine: low CPU and disk priority, and one run at a time. renice -n 10 -p $$ >/dev/null 2>&1 have() { command -v "$1" >/dev/null 2>&1; } have ionice && ionice -c 3 -p $$ >/dev/null 2>&1 LOCK=${TMPDIR:-/tmp}/emudhra-check.lock if ! mkdir "$LOCK" 2>/dev/null; then other=$(cat "$LOCK/pid" 2>/dev/null) if [ -n "$other" ] && kill -0 "$other" 2>/dev/null; then echo "Another emudhra-check run (pid $other) is still going; wait for it to finish." >&2; exit 2 fi rm -rf "$LOCK"; mkdir "$LOCK" || exit 2 # left behind by a run that was killed fi echo $$ > "$LOCK/pid" trap 'rm -rf "$LOCK"' EXIT trap 'exit 130' INT TERM HUP # so the EXIT trap runs 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)); } # 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" "$LOCK"' 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" # has_bytes PATTERNS FILE: true if FILE contains a line of PATTERNS. macOS's BSD grep -F -f # is ~50x slower than perl, and ripgrep ~4x faster still. ripgrep rejects the raw-byte # patterns as invalid UTF-8, so it gets them as (?-u:\xNN...) escapes, and any rg error # falls back to the slower matcher instead of counting as "not found". if have perl; then slow_has_bytes() { perl -e 'open P, "<", $ARGV[0] or exit 2; chomp(my @p =

); my $re = join "|", map quotemeta, @p; open F, "<:raw", $ARGV[1] or exit 2; my $t = ""; while (read F, my $b, 1 << 24) { $t .= $b; exit 0 if $t =~ /$re/o; $t = substr $t, -100 if length $t > 100 } exit 1' "$1" "$2"; } else slow_has_bytes() { LC_ALL=C grep -qsF -f "$1" "$2"; } fi if have rg; then has_bytes() { [ -f "$1.rx" ] || od -An -v -tx1 "$1" | awk '{ for (i = 1; i <= NF; i++) if ($i == "0a") { print "(?-u:" s ")"; s = "" } else s = s "\\x" $i }' > "$1.rx" rg --no-config -qa -f "$1.rx" -- "$2" 2>/dev/null case $? in 0) return 0 ;; 1) return 1 ;; esac slow_has_bytes "$1" "$2" } else has_bytes() { slow_has_bytes "$1" "$2"; } fi bundles_root() { has_bytes "$TMP/root" "$1"; } has_blocklist() { has_bytes "$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 | LC_ALL=C 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 the 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. Hosts are probed six at a time; one # trusted chain is enough for a FAIL, so a client that trusts one of the first six # (which cover both roots and the noct hosts) is not probed further. Unreached hosts # get one retry. probe_hosts() { i=0 for h in $1; do ( probe "$h" >/dev/null 2>&1; echo $? > "$TMP/rc.$h" ) & i=$((i + 1)); [ $((i % 6)) -eq 0 ] && wait done wait } # tally HOSTS: sets bad, unknown, and the verdict per path (emsign, aaa, noct): trusted # if any host on that path was trusted, rejected if all were, else "no answer". tally() { bad= unknown= emsign=rejected aaa=rejected noct=rejected for h in $1; do case $h in *-noct-*) route=noct ;; *-aaa-*) route=aaa ;; *) route=emsign ;; esac case $(cat "$TMP/rc.$h" 2>/dev/null) in 0) bad="$bad $h"; eval "$route=trusted" ;; 3) ;; *) unknown="$unknown $h"; eval "[ \"\$$route\" = trusted ] || $route='no answer'" ;; esac done } 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 rm -f "$TMP"/rc.* probe_hosts "$FIRST"; tally "$FIRST" [ -z "$bad" ] && { probe_hosts "$REST"; tally "$BAD_HOSTS"; } [ -z "$bad" ] && [ -n "$unknown" ] && { probe_hosts "$unknown"; tally "$BAD_HOSTS"; } nu=$(echo $unknown | wc -w | tr -d ' ') if [ -n "$bad" ]; then case $noct in trusted) ct="; it also accepts certificates never logged to Certificate Transparency, so an attack would leave no public trace" ;; rejected) ct="; it does require Certificate Transparency, so an attack would at least be publicly logged" ;; *) ct= ;; esac fail "$name TRUSTS the compromised chains (via the emSign root: $emsign; via the Sectigo AAA cross-sign: $aaa)$ct" "$@" result=fail exposed="$exposed - ${name%% (*}" nixish "$CA" && NIX_FAILED=1 elif [ "$nu" -eq "$NHOSTS" ]; then skip "$name: could not reach any compromised test host (network or proxy blocking or intercepting them, or the test site is overloaded), so it was not tested"; result=skip elif [ -n "$unknown" ]; then skip "$name: rejected the others, but $nu test host(s) did not answer ($(echo $unknown | sed 's/-emudhra[^ ]*//g; s/ /, /g')), so rejection is unconfirmed"; result=skip else ok "$name rejects all $NHOSTS compromised chains"; result=ok; fi } BREW=$(brew --prefix 2>/dev/null) # Without this, brew upgrade also upgrades every installed formula that depends on the one named. BREWUP="HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 brew upgrade" 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 "freebsd-update fetch install (base certificates fixed in 15.1-RELEASE-p6, 14.5-RELEASE-p3 and 14.4-RELEASE-p12), and 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 is fixed only through Ubuntu Pro (ESM): sudo pro enable esm-infra && sudo apt-get update && sudo apt-get install --only-upgrade ca-certificates. Without Pro, workaround: $DEB_WORKAROUND" ;; *) echo "sudo apt-get update && sudo apt-get install --only-upgrade ca-certificates (fixed in 20260816ubuntu0.$VERSION_ID.1)" ;; esac ;; debian|devuan) case ${VERSION_CODENAME:-} in bookworm|daedalus) echo "sudo apt-get update && sudo apt-get install --only-upgrade ca-certificates (fixed in 20250419~deb12u2 from bookworm-security)" ;; trixie|excalibur) echo "sudo apt-get update && sudo apt-get install --only-upgrade ca-certificates (fixed in 20250419+deb13u1 from trixie-security)" ;; *) echo "On testing and unstable: sudo apt-get update && sudo apt-get install --only-upgrade ca-certificates (fixed in 20260816+nmu1; it may take a day to reach testing mirrors). Releases before bookworm have no fix; workaround: $DEB_WORKAROUND" ;; esac ;; fedora) echo "sudo dnf upgrade --refresh --enablerepo=updates-testing ca-certificates (fixed in 2026.2.92_v9.0.319; still in updates-testing for Fedora 43-45)" ;; rhel|centos|rocky|almalinux|amzn) echo "No package fix yet. 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)" ;; guix) echo "guix pull && guix upgrade (fixed in Guix 7608a99; on Guix System also sudo guix system reconfigure)" ;; 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 (2026.10.19+) in the Python environment that owns $1" ;; */Python.framework/*) echo "python.org Python: install 3.15.1, 3.14.9 or 3.13.17, or run 'Install Certificates.command' in /Applications/Python 3.x/" ;; *homebrew*|*linuxbrew*|/usr/local/etc/*|/usr/local/opt/*|/usr/local/Cellar/*) echo "brew update && $BREWUP 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 # py_fix PKG: how to upgrade a package for python3. Pythons from Homebrew, Nix or the OS # refuse pip installs (EXTERNALLY-MANAGED), so name their package manager instead. py_fix() { if python3 -c 'import os, sys, sysconfig sys.exit(not os.path.exists(os.path.join(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED")))' 2>/dev/null; then p=$(command -v python3); p=$(readlink -f "$p" 2>/dev/null || echo "$p") case $p in "${BREW:-/nonexistent}"/*) [ "$1" = pip ] && set -- "$(python3 -c 'import sys; print("python@%d.%d" % sys.version_info[:2])')" echo "brew update && $BREWUP $1" ;; /nix/*) echo "This Python comes from Nix: update it with your Nix setup once nixpkgs has the fix, or use a virtualenv" ;; *) echo "This Python is managed by your OS: upgrade its python3-$1 package (e.g. sudo apt-get install --only-upgrade python3-$1), or use a virtualenv" ;; esac else echo "python3 -m pip install -U $1"; fi } echo "eMudhra compromised-intermediate check ($SITE)" echo echo "== TLS clients (live test: a FAIL here can be exploited now, because the keys are public) ==" # curl_rc RC HOST: probe verdict from curl's exit code (60/51: certificate rejected) and, # for a test host, whether the page that came back is really the test page. curl_rc() { case $1 in 0) test_host "$2" || return 0; grep -qF "$EXPECTED" "$TMP/body.$2" 2>/dev/null && return 0; return 2 ;; 60|51) return 3 ;; esac return 2 } APPLE= if [ "$OS" = Darwin ] && /usr/bin/curl -V 2>/dev/null | grep -q SecureTransport; then # Apple's curl with its Secure Transport backend verifies with Apple's own trust # evaluation (keychain plus Apple's blocklist) instead of /etc/ssl/cert.pem. A CA file # would replace the system trust, so ignore ~/.curlrc (-q) and the CA variables. probe() { env -u SSL_CERT_FILE -u SSL_CERT_DIR -u CURL_CA_BUNDLE CURL_SSL_BACKEND=secure-transport \ /usr/bin/curl -q -s -o "$TMP/body.$1" --max-time 10 "https://$1/" curl_rc $? "$1"; } live "macOS TLS (Safari, URLSession, Swift, Go on macOS, ...)" \ "Apple blocks these through its revocation database, which trustd refreshes in the background: keep this Mac online for a few minutes (or restart it) and rerun" \ "If it still fails, 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) test_host "$1" && case $out in *eMudhra*) ;; *) return 2 ;; esac # not the test chain (a proxy?) 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 "$TMP/body.$1" --max-time 10 "https://$1/"; curl_rc $? "$1"; } 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[3] if len(sys.argv) > 3 else None) CertError = getattr(ssl, "SSLCertVerificationError", ()) test = "-emudhra." in sys.argv[1] try: body = urllib.request.urlopen("https://" + sys.argv[1] + "/", timeout=10, context=ctx).read() except urllib.error.HTTPError: sys.exit(2 if test else 0) 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) sys.exit(2 if test and sys.argv[2].encode() not in body else 0)' "$1" "$EXPECTED" ${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, ...)" "$(py_fix certifi) (fixed in 2026.10.19)" fi fi if have nix-instantiate; then STAMP=$$$(date +%s) # unique URL, so Nix's download cache cannot answer probe() { if out=$(tmo nix-instantiate --eval -E "builtins.readFile (builtins.fetchurl \"https://$1/?emudhra-check=$STAMP\")" \ --option tarball-ttl 0 --option connect-timeout 10 2>&1); then test_host "$1" || return 0 case $out in *"$EXPECTED"*) return 0 ;; esac return 2 fi 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.js and Bun: complete the handshake without enforcing verification and read the # verdict from socket.authorized, so every certificate error (including INVALID_PURPOSE # from Nix reject rules) counts as a rejection and every network error as unknown. A test # host that does not present the eMudhra chain (a TLS-intercepting proxy) is unknown too. JSPROBE='const h = process.argv[process.argv.length - 1] process.on("uncaughtException", () => process.exit(2)) const s = require("tls").connect({ host: h, port: 443, servername: h, rejectUnauthorized: false }, () => { const c = s.getPeerCertificate() if (h.includes("-emudhra.") && !/eMudhra/.test(JSON.stringify((c && c.issuer) || {}))) process.exit(2) process.exit(s.authorized ? 0 : 3) }) s.on("error", () => process.exit(2)); s.setTimeout(10000, () => process.exit(2))' BUNDLED="processes started without this shell's CA settings (GUI apps, launchd jobs, CI) still trust the compromised chains" [ "${NODE_TLS_REJECT_UNAUTHORIZED:-}" = 0 ] && warn "NODE_TLS_REJECT_UNAUTHORIZED=0 is set: Node.js and Bun accept any certificate in this shell" "unset NODE_TLS_REJECT_UNAUTHORIZED" NODE=$(command -v node) if [ -n "$NODE" ]; then probe() { node -e "$JSPROBE" "$1"; } nodefix="Upgrade Node.js to 26.12.1, 24.22.1 or 22.23.4 (with nvm/fnm/Volta/mise/brew, whichever installed it). Node 20 and other end-of-life lines have no fix" live "Node.js $(node --version) ($NODE${NODE_OPTIONS:+, NODE_OPTIONS=$NODE_OPTIONS}${NODE_EXTRA_CA_CERTS:+, NODE_EXTRA_CA_CERTS=$NODE_EXTRA_CA_CERTS})" "$nodefix" # Homebrew's node is a small launcher; its bundled root store is in libnode. NODESTORE=$NODE for f in "$(dirname "$(readlink -f "$NODE" 2>/dev/null || echo "$NODE")")"/../lib/libnode.*.dylib; do [ -f "$f" ] && NODESTORE=$f; done [ "$result" != fail ] && trusts_root "$NODESTORE" && warn "Node.js's bundled root store ($NODESTORE) still has an affected root; $BUNDLED, as does --use-bundled-ca" "$nodefix" fi BUN=$(command -v bun) if [ -n "$BUN" ]; then probe() { bun -e "$JSPROBE" "$1"; } bunfix="bun upgrade (fixed in 1.4.4, which removes the roots from Bun's own bundle)" live "Bun $(bun --version) ($BUN)" "$bunfix" "Bun also trusts the OS store, so fix that too: $(os_fix)" [ "$result" != fail ] && trusts_root "$BUN" && warn "Bun's bundled root store ($BUN) still has an affected root; $BUNDLED" "$bunfix" 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 "== Other trust stores and 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 # pip: check the CA file it actually uses. Its vendored certifi is fixed in pip 26.2.2; # Homebrew and distro builds point it at their own CA file instead. if [ -n "$PY" ] && f=$(python3 -c 'import os; from pip._vendor import certifi print(os.environ.get("PIP_CERT") or certifi.where())' 2>/dev/null) && [ -f "$f" ]; then v=$(python3 -m pip --version 2>/dev/null | cut -d' ' -f2) case $f in /Library/Developer/*|/Applications/Xcode*) fix="This is Apple's built-in Python: fix /etc/ssl/cert.pem (see the end), then point pip at it as $SITE describes (PIP_CERT in sitecustomize.py)" ;; */pip/_vendor/*) fix="$(py_fix pip) (fixed in 26.2.2)" ;; *) fix=$(store_fix "$f") ;; esac # pip 24.2+ on Python 3.10+ verifies through the OS (truststore), adding its certifi only # as extra anchors. On macOS that is Apple's trust evaluation, which blocks these chains. pyv=$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])') if [ "$OS" = Darwin ] && [ "$APPLE" = ok ] && ! ver_lt "$v" 24.2 && ! ver_lt "$pyv" 3.10 && ! { echo "${PIP_USE_DEPRECATED:-}"; python3 -m pip config list 2>/dev/null; } | grep -q legacy-certs; then ok "pip $v (verifies through macOS trust via truststore)" elif pem_trusts_root "$f"; then fail "pip $v ($f) trusts an affected root" "$fix" else ok "pip $v ($f)"; fi fi # The opt link names the version in use; `brew list --versions` also lists old kegs that # HOMEBREW_NO_INSTALL_CLEANUP (or a skipped cleanup) left behind, and printed the old one first. if [ -n "$BREW" ] && v=$(readlink "$BREW/opt/ca-certificates" 2>/dev/null); then v=${v##*/} if ver_lt "$v" 2026-09-25_1; then fail "Homebrew ca-certificates $v is not patched" "brew update && $BREWUP ca-certificates (fixed in 2026-09-25_1)" else ok "Homebrew ca-certificates $v"; fi fi # Perl LWP uses Mozilla::CA's bundled cacert.pem. if have perl && f=$(perl -MMozilla::CA -e 'print Mozilla::CA::SSL_ca_file()' 2>/dev/null) && [ -f "$f" ]; then v=$(perl -MMozilla::CA -e 'print $Mozilla::CA::VERSION' 2>/dev/null) case $f in /System/*) fix="This copy is part of macOS and only Apple can update it. For your own scripts install a fixed one (cpanm Mozilla::CA) or set PERL_LWP_SSL_CA_FILE to a fixed bundle" ;; *) fix="cpanm Mozilla::CA (or cpan Mozilla::CA, or your package manager) (fixed in 20261020)" ;; esac if pem_trusts_root "$f"; then fail "Perl Mozilla::CA $v ($f) trusts an affected root" "$fix" else ok "Perl Mozilla::CA $v"; fi fi # Java trusts only its own cacerts file. Patched JDKs either drop the affected roots from it # (Temurin) or keep them and list the 16 compromised certificates, by SHA-256, in # lib/security/blocked.certs (OpenJDK/Oracle). (On macOS /usr/bin/java is a stub; java_home # lists JDKs.) printf '%s\n' \ 42C4ACCD12C4F59597925E1A08A7A90102608543809308D50EC427E118BA1493 45A883F349AEDFD5F3A649B5498FFB121712BA8B448F3F2A91DBC5A766FCDDBE \ 98B896625C0C230ADEEC3EB2CF6AAD5001ECF9156A68644E82374CE5833BC25B 5F1DED4DD590AF74631290CF073B122A2AAC6A1FA479778A0BD43579BD832699 \ E7568FE6E12331F4FD7DE61DA4C7559D0F8EB192A218D4741A3BA75184333838 579480AEA166290BD3C643589E795FC0D0E54E86505D180F1DFF99EFD3199512 \ 0164D7CEAE5BB909C272AF151FF44B1177AE9B47D9424B94F1D9558C30934D19 62B7F9868263F72F6911FC78CEAF72C1979158B7368BF648683BB2AA39B02E72 \ CB6E15165AC802E9B3F993F16C25D41F4E81C21EBDE71AE1B012AC00BF9C7C3F 7485B51C0ED192878FA1D2F948FB529AE1842CC5086C4B67F7BE5B2039DE9489 \ 432EC25F0D28DE9006D97BF752F17BBB2CD41F669933FCB072D9A1148D0261F3 FD9D5C2DBBD4995AB69B8D50C439D9527B00529199C2FD01B2174EB58F07021F \ 9E39DE2881273ACC225A149C72BA3E0F51424963669AB633EE1DC7DF5DB067B4 A496E23AC0CAB8155FC9B117B97EAE368EFCBB9041AEC8804D5A308D2D01C2D8 \ 4B2460A466F95FD40113BCDCCA2CEB29B3B093921DE45474DA7DF48874C630B1 84112972B8E59D14560AE7E7119654474AAD119990CF3722BFC1E3B91615E1FD \ > "$TMP/jdkfp" { [ -n "${JAVA_HOME:-}" ] && echo "$JAVA_HOME" if [ "$OS" = Darwin ]; then /usr/libexec/java_home -V 2>&1 | sed -n 's|.*[[:space:]]\(/.*/Contents/Home\)$|\1|p' printf '%s\n' "$BREW"/opt/openjdk*/libexec/openjdk.jdk/Contents/Home "$HOME"/Library/Java/JavaVirtualMachines/*/Contents/Home else j=$(command -v java) && j=$(readlink -f "$j" 2>/dev/null) && echo "${j%/bin/java}" printf '%s\n' /usr/lib/jvm/* "$BREW"/opt/openjdk*/libexec fi printf '%s\n' "$HOME"/.sdkman/candidates/java/* "$HOME"/.jdks/* "$HOME"/.local/share/mise/installs/java/* "$HOME"/.asdf/installs/java/* } 2>/dev/null | awk 'NF && !seen[$0]++' > "$TMP/jdks" seen=" " while IFS= read -r h; do [ -d "$h/Contents/Home" ] && h=$h/Contents/Home h=${h%/jre} c=$h/lib/security/cacerts; [ -f "$c" ] || c=$h/jre/lib/security/cacerts kt=$h/bin/keytool [ -f "$c" ] && [ -x "$kt" ] || continue ino=$(ls -iLd "$c" | awk '{ print $1 }') case $seen in *" $ino "*) continue ;; esac seen="$seen$ino " v=$(sed -n 's/^JAVA_VERSION="\(.*\)"/\1/p' "$h/release" 2>/dev/null) for b in "$h/lib/security/blocked.certs" "$h/jre/lib/security/blacklisted.certs" "$h/lib/security/blacklisted.certs"; do [ -f "$b" ] && [ "$(grep -oiF -f "$TMP/jdkfp" "$b" | tr a-f A-F | sort -u | wc -l)" -eq 16 ] && break b= done if [ -n "$b" ]; then ok "Java ${v:+$v }($h) blocks the compromised certificates (${b##*/})"; continue; fi tmo "$kt" -list -rfc -keystore "$c" -storepass changeit > "$TMP/jks" 2>/dev/null if ! grep -q '^Alias name:' "$TMP/jks"; then skip "Java ${v:+$v }($h): could not read $c"; continue; fi # One line per certificate: alias, then its base64 joined (the root fragments then match). awk '/^Alias name:/ { a = substr($0, 13) } /-----BEGIN CERTIFICATE-----/ { b = ""; p = 1; next } /-----END CERTIFICATE-----/ { print a "\t" b; p = 0 } p { b = b $0 }' "$TMP/jks" | LC_ALL=C grep -F -f "$TMP/root" | cut -f1 > "$TMP/jalias" if [ ! -s "$TMP/jalias" ]; then ok "Java ${v:+$v }($h)"; continue; fi real=$(readlink -f "$c" 2>/dev/null || echo "$c") case $real in /etc/ssl/certs/java/*) # generated from the OS store (Debian, Ubuntu) set -- "$(os_fix)" "Then resync Java's copy: sudo touch /var/lib/ca-certificates-java/fresh && sudo dpkg-reconfigure ca-certificates-java" ;; /etc/pki/*) set -- "$(os_fix)" ;; *) sudo=; [ -w "$c" ] || sudo="sudo " cmd=$(while IFS= read -r a; do printf "%s'%s' -delete -keystore '%s' -storepass changeit -alias '%s' && " "$sudo" "$kt" "$c" "$a"; done < "$TMP/jalias") set -- "Update this JDK: OpenJDK and Oracle JDK are fixed in 27.0.1.0.2, 25.0.5.0.2, 21.0.13.0.2, 17.0.21.0.2, 11.0.33.0.2 and 8u511; Temurin in 21.0.13; other vendors are shipping their own updates" \ "Until then, remove the roots from this JDK: ${cmd% && }" ;; esac fail "Java ${v:+$v }($h) TRUSTS the compromised chains: its cacerts has $(sed "s/.*/'&'/" "$TMP/jalias" | tr '\n' ';' | sed 's/;$//; s/;/; /g')" "$@" exposed="$exposed - Java ${v:+$v }($h)" done < "$TMP/jdks" 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 p=/Applications/Docker.app/Contents/Info.plist if [ -f "$p" ] && v=$(plutil -extract CFBundleShortVersionString raw -o - "$p" 2>/dev/null); then if ver_lt "$v" 4.96.2; then fail "Docker Desktop $v is not patched" "Update Docker Desktop (its menu: Check for updates) (fixed in 4.96.2)" else ok "Docker Desktop $v"; fi fi 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): rustup self update (fixed in 1.29.2; updating toolchains does not update rustup itself)" 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 "Old Node.js install: upgrade it to 26.12.1, 24.22.1 or 22.23.4, or remove it" ;; */bun|*/bunx) echo "bun upgrade (fixed in 1.4.4)" ;; */claude|*/claude/versions/*|*/.claude/*) echo "claude update (fixed in Claude Code 2.1.303)" ;; */pixi) echo "pixi self-update (fixed in 0.82.1)" ;; */uv) echo "uv self update (or your package manager) (fixed in 0.13.4)" ;; */mise) echo "mise self-update (or your package manager) (fixed in 2026.10.17)" ;; */deno) echo "deno upgrade (fixed in 2.9.8)" ;; */.foundry/*) echo "Update Foundry with foundryup, and check that the new build no longer bundles the root (rerun this script)" ;; */pipx/venvs/*) t=${1#*/pipx/venvs/}; echo "pipx reinstall ${t%%/*} (reinstalls it with current certifi)" ;; */uv/tools/*|"${UVTOOLS:-/nonexistent}"/*) t=${1#"${UVTOOLS:-/nonexistent}"/}; t=${t#*/uv/tools/} echo "uv tool upgrade ${t%%/*} (if it was installed from a local folder or git checkout: uv tool install --reinstall )" ;; */.cargo/bin/*) c=$(cargo install --list 2>/dev/null | awk -v b="${1##*/}" '/^[^ ]/ { c = $1 } $1 == b { print c; exit }') echo "Once ${c:-the crate} has a fixed release: cargo install --locked ${c:-}. Before that, cargo install ${c:-} (without --locked) picks up webpki-roots 1.0.10 / rustls-webpki 0.103.16 but may not build" ;; */node_modules/*) echo "Update the global npm package that ships it (npm update -g), or ask its maintainer" ;; "${BREW:-/nonexistent}"/Caskroom/*) t=${1#"$BREW"/Caskroom/}; echo "brew update && $BREWUP --cask ${t%%/*}" ;; "${BREW:-/nonexistent}"/Cellar/*) t=${1#"$BREW"/Cellar/}; echo "brew update && $BREWUP ${t%%/*}" ;; *) 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 } if [ -n "$QUICK" ]; then skip "Bundled root stores in installed tools were not scanned (--quick); run without --quick to finish the check" else 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) # scan_find [-L] DIR [-maxdepth N]: CA bundles, and executables and native libraries over # 1 MB (where embedded root stores usually live). --deep reads every file over 1 MB and # every .pem/.crt, which also catches stores inside WASM, JS bundles, JARs and data files. scan_find() { if [ -n "$DEEP" ]; then find "$@" -type f \( -name '*.pem' -o -name '*.crt' -o -size +1000k \) 2>/dev/null else find "$@" -type f \( -name cacert.pem -o -name certs.pem -o -size +1000k \( -perm -100 \ -o -name '*.node' -o -name '*.so' -o -name '*.so.*' -o -name '*.dylib' \) \) 2>/dev/null fi } { # 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" ] && scan_find "$r" done # 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/bin" "$HOME/.foundry/bin" } | awk 'NF && !seen[$0]++' | while IFS= read -r d; do [ -d "$d" ] && scan_find -L "$d" -maxdepth 1 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 "$BREW"/opt/node*/lib/libnode.*.dylib; 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" ] || continue if [ -n "$DEEP" ]; then scan_find "$d"; else find "$d" -name cacert.pem 2>/dev/null; fi done # --deep: also everything under /opt and ~/.local/share (mise, asdf, fnm, uv Pythons, ...). [ -n "$DEEP" ] && for d in /opt "$HOME/.local/share"; do [ -d "$d" ] && scan_find "$d"; 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" ] && scan_find "$d" 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 <&2 # progress, on a terminal only # Skip what was live-tested above, and report each physical file once. if [ "$f" -ef "$NODE" ] || [ "$f" -ef "${NODESTORE:-}" ] || [ "$f" -ef "$BUN" ]; then continue; fi ino=$(ls -iLd "$f" 2>/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" [ -t 2 ] && printf ' \r' >&2 fi # 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 case $v in 0.104.0-alpha.*) need=0.104.0-alpha.8 ;; *) need=0.103.16 ;; esac # the 0.104 prereleases got the fix in alpha.8 if ver_lt "$v" $need; then fail "$2: rustls-webpki $v" "cargo update -p rustls-webpki@$v (needs 0.103.16+, or 0.104.0-alpha.8+ on the 0.104 prereleases; 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 "$DIR" ]; then echo if [ -n "$REPOS" ]; then echo "== Lockfiles in every repo under $DIR (6 levels deep; problems only, grouped by repo) ==" DEPTH=6 else echo "== Lockfiles in $DIR (4 levels deep) ==" DEPTH=4 fi [ -t 2 ] && printf 'Searching %s ...\r' "$DIR" >&2 find "$DIR" -maxdepth $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 Cargo.toml -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" [ -t 2 ] && printf '%s\r' ' ' >&2 sed -n 's|/\.git$||p' "$TMP/found" > "$TMP/repos" nrepos=$(wc -l < "$TMP/repos" | tr -d ' ') [ -n "$REPOS" ] || echo "$DIR" > "$TMP/repos" # "repolockfile" ("." outside any repo), sorted so each repo's results print together. grep -v -e '/\.git$' -e '/Cargo\.toml$' "$TMP/found" | awk 'FILENAME == ARGV[1] { r[$0]; next } { best = "."; for (d in r) if (index($0, d "/") == 1 && length(d) > length(best)) best = d print best "\t" $0 }' "$TMP/repos" - | sort > "$TMP/locks" # In --repos mode, show only problems. QUIET=$REPOS nlocks=0 before=$fails last= while IFS=' ' read -r repo 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)) base=$repo; [ "$repo" = . ] && base=$DIR check_lock "$p" "${p#"$base"/}" > "$TMP/out" [ -s "$TMP/out" ] || continue if [ -n "$REPOS" ]; then if [ "$repo" != "$last" ] && [ "$repo" != . ]; then echo "${repo#"$DIR"/}:"; last=$repo; fi sed 's/^/ /' "$TMP/out" else cat "$TMP/out"; fi done < "$TMP/locks" QUIET= # Rust projects without a Cargo.lock in their folder or a parent: versions unknown. grep '/Cargo\.lock$' "$TMP/found" | sed 's|/[^/]*$||' > "$TMP/lockdirs" grep '/Cargo\.toml$' "$TMP/found" | sed 's|/[^/]*$||' | awk 'FILENAME == ARGV[1] { l[$0]; next } { d = $0; while (1) { if (d in l) next; if (d !~ /\//) break; sub(/\/[^\/]*$/, "", d) } print $0 }' "$TMP/lockdirs" - | sort | awk 'NR == 1 || index($0, prev "/") != 1 { print; prev = $0 }' > "$TMP/nolock" nn=$(wc -l < "$TMP/nolock" | tr -d ' ') if [ "$nn" -gt 0 ]; then list=$(head -n 5 "$TMP/nolock" | sed "s|^$DIR/||" | tr '\n' ',' | sed 's/,$//; s/,/, /g') [ "$nn" -gt 5 ] && list="$list and $((nn - 5)) more" warn "$nn Rust project(s) have no Cargo.lock, so their resolved dependency versions could not be checked: $list" \ "To check one: cargo generate-lockfile in its folder, then rerun (libraries often do not commit the file; delete it afterwards if so)" fi 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)${REPOS:+ in $nrepos git repo(s)}: $((fails - before)) problem(s)." fi echo "Lockfiles show source dependencies only; binaries already built from them stay vulnerable until rebuilt." 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 the built-in curl, git, python3, perl, ruby and openssl) still contains them. Run:" echo cat <<'EOF' sudo cp -p /etc/ssl/cert.pem /etc/ssl/cert.pem.bak && sudo 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 echo echo "Then make the built-in pip3 (and venvs made from the built-in python3) use the fixed file:" echo cat <<'EOF' F="$(/usr/bin/python3 -c 'import sysconfig; print(sysconfig.get_path("stdlib"))')/sitecustomize.py" && echo 'import os; os.environ.setdefault("PIP_CERT", "/etc/ssl/cert.pem")' | sudo tee -a "$F" > /dev/null && sudo chmod 644 "$F" 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 nexp=$(printf '%s\n' "$exposed" | grep -c '^ - ') if [ "$nexp" -gt 0 ]; then echo "${R}URGENT: $nexp TLS client(s) on this machine trust the compromised chains right now:${N}$exposed" echo "Fix these first; anything using them can be attacked today." fi [ "$fails" -gt "$nexp" ] && echo "${R}$((fails - nexp)) other item(s) need updating${N} (CA bundles, tool versions, bundled root stores${DIR:+, lockfiles}): see FAIL above." [ "$warns" -gt 0 ] && echo "${Y}$warns warning(s)${N}: see WARN above." [ -n "$DIR" ] || echo "Project lockfiles were not checked. To check them: add a project folder, or --repos , after 'sh -s --'." echo "Not covered: containers, VMs, Docker images, JARs, and apps that pin certificates. Details: $SITE" if [ "$fails" -gt 0 ]; then exit 1; fi if [ "$skips" -gt 0 ]; then echo "${Y}No failures, but $skips check(s) did not complete, so this machine is not cleared.${N} Fix the SKIP items and rerun." exit 3 fi echo "${G}No issues found by these checks.${N} Keep your browser and OS up to date." }