A Jamf Pro Extension Attribute and remediation script duo
Update 26-Oct-2026: macOS
27.2 (26B123) / 26.7.2 (25G332)requires remediation

Background
On, or around, October 17–20, 2026, it became public that eight intermediate CA private keys from Indian certificate authority eMudhra were compromised.
The certificates were generated with extremely weak entropy during a key ceremony in April 2025—P-384 elliptic-curve keys that were tiny integers under 256 (one was literally the number 4). Anyone could recover them almost instantly from the public certificates.
Apple’s block of the leaked eMudhra intermediates protects only Security.framework apps, with binaries such as curl, git, python3 and openssl still trusting /etc/ssl/cert.pem , which holds the root-trust anchors, and the leaked intermediates chain to those roots (AAA Certificate Services and emSign ECC Root CA – G3).
Remediation
Here’s a Jamf Pro Extension Attribute to identify exposed Macs and a remediation script to safely remove both roots (and which can continue removing them after every macOS update).
As always, caveat emptor.
Inspired by
u/Next-Passenger7430post on r/macsysadmins.
Jamf Pro Extension Attribute
#!/bin/zsh --no-rcs
####################################################################################################
#
# cert.pem eMudhra Status
#
# Purpose: Reports whether /etc/ssl/cert.pem still trusts the leaked eMudhra intermediates
#
# Jamf Pro Extension Attribute Data Type: String
#
# Results: Remediated Neither target root present and both leaked intermediates refused
# Exposed A target root present or a leaked intermediate still verifies
# Damaged cert.pem missing, unreadable, a symlink, empty or holding an unparsable
# certificate
# Unknown openssl missing, an embedded probe altered, an unexpected verify result or
# any other failure
#
# Notes: Constants, probes and verification logic copied from Remediate-cert.pem-eMudhra.zsh
# (0.0.3); writes only to a mktemp -d work directory, removed on exit
#
####################################################################################################
#
# HISTORY
#
# Version 0.0.1, 21-Oct-2026, Dan K. Snelson (@dan-snelson)
# Original version
#
####################################################################################################
####################################################################################################
#
# Variables
#
####################################################################################################
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
# Jamf Pro reads only stdout; discard all stderr
exec 2>/dev/null
certFile="/etc/ssl/cert.pem"
opensslBinary="/usr/bin/openssl"
# Offline verification time (21-Oct-2026 00:00:00 UTC), pinned so certificate expiry never changes
# the verdict; LibreSSL's openssl verify has -attime, but not -no_check_time
verifyEpoch="1792540800"
# SHA-256 fingerprints of the roots to remove (uppercase hex, no colons)
typeset -A targetRoots
targetRoots=(
D7A7A0FB5D7E2731D771E9484EBCDEF71D5F0C3E0A2948782BC83EE0EA699EF4 "AAA Certificate Services"
86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B "emSign ECC Root CA - G3"
)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Offline Probes: one leaked intermediate per target root; each verifies against a bundle that
# still holds its issuing root and fails with "error 20" once that root is removed
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
typeset -A probeLabels probeFingerprints probePEMs
probeLabels=(
emSign "emSign Root TLS CA - G3 <- emSign ECC Root CA - G3"
AAA "EM DV TLS CA - G3B <- AAA Certificate Services"
)
probeFingerprints=(
emSign 3094025792039A68AF6E553686410BDAB7C0D4A36B9084CBA64202E35D6EBBDD
AAA 45A883F349AEDFD5F3A649B5498FFB121712BA8B448F3F2A91DBC5A766FCDDBE
)
# Served by the advisory's test host (crt.sh ID 14658191778)
probePEMs[emSign]='-----BEGIN CERTIFICATE-----
MIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw
CQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy
YSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290
IENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD
VQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg
MB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr
gQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m
WO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw
LKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj
QjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB
BQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB
FhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi
BggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4
aHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290
Q0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t
P1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB
ATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg
TroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0
1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV
-----END CERTIFICATE-----'
# Cross-signed by AAA Certificate Services (crt.sh ID 18519019583)
probePEMs[AAA]='-----BEGIN CERTIFICATE-----
MIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw
ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV
BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y
ODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW
MRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA
IgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO
L4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2
PM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd
BgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG
A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC
MDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h
AQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB
QUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF
BQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB
AQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE
wqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM
QkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ
7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl
bN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V
GUyRCEIDhF5TBUuKF4E3kf1h
-----END CERTIFICATE-----'
####################################################################################################
#
# Functions
#
####################################################################################################
# Emit exactly one Jamf Pro Extension Attribute result, then stop.
function printResult() {
/usr/bin/printf '<result>%s</result>\n' "${1}"
exit 0
}
# Remove the work directory (the only place this script writes).
function cleanUp() {
[[ -n "${workDir}" && -d "${workDir}" ]] && /bin/rm -Rf "${workDir}"
}
# Split every PEM certificate block in a file (Parameter 1) into its own file in a directory
# (Parameter 2)
function splitPEM() {
local pemFile="${1}"
local splitDir="${2}"
rm -Rf "${splitDir}"
mkdir -p "${splitDir}" || return 2
awk -v dir="${splitDir}" '
/^-----BEGIN CERTIFICATE-----/ { n++; inBlock=1; file=sprintf("%s/%04d.pem", dir, n) }
inBlock { print > file }
/^-----END CERTIFICATE-----/ { if (inBlock) close(file); inBlock=0 }
' "${pemFile}"
}
# SHA-256 fingerprint (uppercase hex, no colons) of a single-certificate PEM file (Parameter 1)
function certFingerprint() {
local fingerprint
fingerprint=$( "${opensslBinary}" x509 -noout -fingerprint -sha256 -in "${1}" 2>/dev/null ) || return 2
fingerprint="${${fingerprint#*=}//:/}"
fingerprint="${(U)fingerprint}"
[[ "${fingerprint}" =~ ^[0-9A-F]{64}$ ]] || return 2
echo "${fingerprint}"
}
# Write sorted SHA-256 fingerprints of every parsed certificate in a PEM bundle (Parameter 1) to an
# output file (Parameter 2); returns 2 if any block fails to parse or the bundle holds none
function listFingerprints() {
local pemFile="${1}"
local outFile="${2}"
local splitDir="${workDir}/split.${outFile:t}"
local -a blocks
local block
# Fingerprint the PEM data itself, not the "===" header text
splitPEM "${pemFile}" "${splitDir}" || return 2
blocks=( "${splitDir}"/*.pem(N) )
(( ${#blocks} > 0 )) || return 2
# Fast path: one LibreSSL interactive-mode process fingerprints every block (one fork per block
# costs ~13 ms, ~2 s for Apple's bundle); relative names, as interactive mode splits on whitespace
(
cd "${splitDir}" || exit 2
for block in "${blocks[@]:t}"; do
print -r -- "x509 -noout -fingerprint -sha256 -in ${block}"
done | "${opensslBinary}"
) | /usr/bin/grep -oE 'SHA256 Fingerprint=([0-9A-F]{2}:){31}[0-9A-F]{2}' \
| /usr/bin/sed -e 's/^.*=//' -e 's/://g' > "${outFile}"
# Fallback unless the fast path returned exactly one fingerprint per block (e.g., OpenSSL 3 has
# no interactive mode, or a block failed to parse): one fork per block, which then decides
# whether the bundle is damaged
if (( $( /usr/bin/wc -l < "${outFile}" ) != ${#blocks} )); then
: > "${outFile}"
for block in "${blocks[@]}"; do
certFingerprint "${block}" >> "${outFile}" || return 2
done
fi
[[ -s "${outFile}" ]] || return 2
sort -o "${outFile}" "${outFile}"
}
# Write the embedded offline probes to workDir and confirm each matches its pinned fingerprint
function writeProbes() {
local probe fingerprint
for probe in ${(k)probeFingerprints}; do
print -r -- "${probePEMs[$probe]}" > "${workDir}/probe.${probe}.pem"
fingerprint=$( certFingerprint "${workDir}/probe.${probe}.pem" )
[[ "${fingerprint}" == "${probeFingerprints[$probe]}" ]] || return 2
done
}
# Offline verification of a bundle (Parameter 1) against the embedded leaked intermediates
# Sets: offlineResult (trusted | refused | inconclusive)
function offlineVerify() {
local bundle="${1}"
local probe verifyOutput verifyRC refusal
local probesTrusted=0
local probesRefused=0
for probe in ${(k)probeFingerprints}; do
verifyOutput=$( "${opensslBinary}" verify -attime "${verifyEpoch}" -CAfile "${bundle}" "${workDir}/probe.${probe}.pem" 2>&1 )
verifyRC=$?
# "error 20" / "error 2": no path from the probe to a trust anchor in the bundle
refusal=$( print -r -- "${verifyOutput}" | grep -m1 -E '^error (2|20) at ' )
if [[ ${verifyRC} -eq 0 && "${verifyOutput}" == *": OK" ]]; then
(( probesTrusted += 1 ))
elif [[ -n "${refusal}" ]]; then
(( probesRefused += 1 ))
fi
done
if (( probesTrusted > 0 )); then
offlineResult="trusted"
elif (( probesRefused == ${#probeFingerprints} )); then
offlineResult="refused"
else
offlineResult="inconclusive"
fi
}
####################################################################################################
#
# Status Check
#
####################################################################################################
if [[ -L "${certFile}" ]] || [[ ! -f "${certFile}" || ! -r "${certFile}" ]]; then
printResult "Damaged"
fi
[[ -x "${opensslBinary}" ]] || printResult "Unknown"
trap cleanUp EXIT
workDir=$( /usr/bin/mktemp -d -t "cert.pem-eMudhra-Status" ) || printResult "Unknown"
[[ -d "${workDir}" ]] || printResult "Unknown"
writeProbes || printResult "Unknown"
listFingerprints "${certFile}" "${workDir}/bundle.txt" || printResult "Damaged"
for targetFingerprint in ${(k)targetRoots}; do
/usr/bin/grep -qxF "${targetFingerprint}" "${workDir}/bundle.txt" && printResult "Exposed"
done
offlineVerify "${certFile}"
case "${offlineResult}" in
trusted ) printResult "Exposed" ;;
refused ) printResult "Remediated" ;;
* ) printResult "Unknown" ;;
esac
Jamf Pro Remediation Script
Impact of removing AAA Certificate Services
AAA Certificate Services is a legacy Sectigo (formerly Comodo) root, not an eMudhra root. It expires in 2028 and was used in May 2025 to cross-sign the eight compromised eMudhra intermediates (the same public keys appear under both the eMudhra roots and this Sectigo root). Removing it from
/etc/ssl/cert.pemis required to close that path for the tools that trust the file.Mozilla and Chrome already dropped TLS trust for this root in April 2025 under their 15-year root lifetime policies. Apple’s trust store still included it, which is why it remains in current macOS
cert.pem. Modern Sectigo chains normally prefer newer roots (USERTrust RSA/ECC, COMODO RSA/ECC); pure reliance on the AAA root is uncommon on up-to-date systems.Because the change only affects clients that read
/etc/ssl/cert.pem(systemcurl,git,python3,openssl,perl,ruby, etc.), Safari and otherSecurity.frameworkapplications are unaffected. Any certificate chain that can still be built solely through the AAA root will fail verification in those tools after the removal.The remediation script creates a timestamped backup and supports a full restore, so the change is reversible if unexpected breakage appears.
#!/bin/zsh --no-rcs
# shellcheck shell=bash
####################################################################################################
#
# Remediate cert.pem eMudhra
#
# Purpose: Removes the two roots the leaked eMudhra intermediates chain to from macOS's
# /etc/ssl/cert.pem, which the built-in curl, git, python3, openssl, perl and ruby
# verify against (Apple's block only covers Security.framework clients)
#
# Usage: Jamf Pro: Parameter 4: Operation Mode [ fix (default) | check | dry-run | restore ]
# Parameter 5: Backup to restore (optional; defaults to newest)
# Parameter 6: Network Test [ off (default) | on ]
# Parameter 7: Test URL (optional; defaults to the advisory's test host)
#
# Local: sudo zsh Remediate-cert.pem-eMudhra.zsh # Fix (default)
# zsh Remediate-cert.pem-eMudhra.zsh --check # Read-only status
# zsh Remediate-cert.pem-eMudhra.zsh --dry-run # Build and verify; install nothing
# sudo zsh Remediate-cert.pem-eMudhra.zsh --restore [backup] # Restore a backup
# Append --offline to any local option to skip the network test
#
# Exit: 0 = safe / fixed; 1 = still exposed; 2 = couldn't tell / refused
#
# Notes:
# - Wraps the advisory's awk removal with the safety checks described in:
# https://www.reddit.com/r/macsysadmin/s/AuiHCkt7Ev
# - If the awk output doesn't validate (e.g., a bundle without Apple's "###" / "===" headers),
# the bundle is rebuilt block by block, dropping only the target roots by SHA-256; the same
# validation gates the install
# - Verdicts are offline: /usr/bin/openssl verifies one embedded leaked intermediate per target
# root against the bundle, so no network access is required
# - The optional network test only confirms: it pins the served chain to the leaked
# intermediates, so a changed test host or TLS interception (e.g., Palo Alto decryption) is
# reported as "invalid" and ignored; it never triggers a rollback
# - "AAA Certificate Services" is the Comodo / Sectigo legacy root, not an eMudhra root; it
# cross-signed eMudhra's "EM DV / EV TLS CA" intermediates, so the advisory removes it
# - macOS updates restore Apple's cert.pem; re-run after each update
# - Out of scope: Apple's pip3 (and venvs made from /usr/bin/python3) carries its own roots and
# needs the advisory's PIP_CERT sitecustomize fix (see Remediate-pip-eMudhra.zsh); official
# Node.js needs its own update
#
####################################################################################################
#
# HISTORY
#
# Version 0.0.1, 21-Oct-2026, Dan K. Snelson (@dan-snelson)
# - Original, proof-of-concept version
#
# Version 0.0.2, 21-Oct-2026, Dan K. Snelson (@dan-snelson)
# - Added Jamf Pro Script Parameters (Parameter 4: Operation Mode; Parameter 5: Backup to restore)
# - Local options (i.e., --check, --dry-run, --restore) unchanged
#
# Version 0.0.3, 21-Oct-2026, Dan K. Snelson (@dan-snelson)
# - Verdicts now offline: embedded leaked intermediates (one per target root) verified against
# the bundle; the network can no longer trigger a rollback
# - Network test optional (Parameter 6; off by default for Jamf Pro, on for local runs unless
# --offline) and pinned to the leaked chain; a mismatch (host change or TLS interception) is
# reported as "invalid", never as "trusted"
# - Added Test URL override (Parameter 7)
#
# Version 0.0.4, 21-Oct-2026, Dan K. Snelson (@dan-snelson)
# - Fallback removal by SHA-256 fingerprint when the advisory awk's output doesn't validate
# (e.g., a bundle without Apple's "###" / "===" headers); same validation gates the install
#
####################################################################################################
####################################################################################################
#
# Global Variables
#
####################################################################################################
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
# Script Version
scriptVersion="0.0.4"
# Client-side Log
scriptLog="/var/log/org.churchofjesuschrist.log"
# Elapsed Time
SECONDS="0"
# Advisory test host: serves a leaf issued under the leaked chain (curl exit 0 = trusted; 60 = refused)
defaultTestURL="https://dv-g3b-emudhra.we-have-a-problem.com/"
# Network verdict until a network test runs
networkResult="disabled"
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Jamf Pro Script Parameters
# (Parameters 1–3 are reserved by Jamf Pro: mount point, computer name and username; local runs
# instead pass no arguments or an option such as --check; see "Pre-flight Check: Parameters")
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Parameter 4: Operation Mode [ fix (default) | check | dry-run | restore ]
operationMode="${4:-"fix"}"
# Parameter 5: Backup to restore (optional; defaults to newest; only used when Operation Mode is restore)
restoreSource="${5:-""}"
# Parameter 6: Network Test [ off (default) | on ]; "on" contacts the Test URL and the control URL
networkTest="${6:-"off"}"
# Parameter 7: Test URL (optional; must serve a chain that includes a pinned leaked intermediate)
testURL="${7:-"${defaultTestURL}"}"
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Organization Variables
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Script Human-readabale Name
humanReadableScriptName="Remediate cert.pem eMudhra"
# Organization's Script Name
organizationScriptName="RCPE"
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Certificate Variables
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
certDir="/etc/ssl"
certFile="${certDir}/cert.pem"
# Positive control (network test only): must still verify with the cleaned bundle
controlHost="www.apple.com"
controlURL="https://${controlHost}/"
curlTimeout="15"
# Offline verification time (21-Oct-2026 00:00:00 UTC), pinned so certificate expiry never changes
# the verdict; LibreSSL's openssl verify has -attime, but not -no_check_time
verifyEpoch="1792540800"
# SHA-256 fingerprints of the roots to remove (uppercase hex, no colons)
typeset -A targetRoots
targetRoots=(
D7A7A0FB5D7E2731D771E9484EBCDEF71D5F0C3E0A2948782BC83EE0EA699EF4 "AAA Certificate Services"
86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B "emSign ECC Root CA - G3"
)
# Advisory awk: each certificate in Apple's cert.pem is preceded by a "### " (organization) and/or
# "=== " (subject) header line; skip from a target's "=== " line to the next header line
removalAwk='
/^(###|===) / { skip=0 }
/^=== .*\/CN=(AAA Certificate Services|emSign ECC Root CA - G3)$/ { skip=1 }
!skip
'
# Network test: the served chain must include one of these leaked intermediates; anything else
# means the test host changed or the connection was intercepted (e.g., TLS decryption)
pinnedChainFingerprints=(
42C4ACCD12C4F59597925E1A08A7A90102608543809308D50EC427E118BA1493 # EM DV TLS CA - G3B <- emSign Root TLS CA - G3
3094025792039A68AF6E553686410BDAB7C0D4A36B9084CBA64202E35D6EBBDD # emSign Root TLS CA - G3 <- emSign ECC Root CA - G3
45A883F349AEDFD5F3A649B5498FFB121712BA8B448F3F2A91DBC5A766FCDDBE # EM DV TLS CA - G3B <- AAA Certificate Services
)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Offline Probes: one leaked intermediate per target root; each verifies against a bundle that
# still holds its issuing root and fails with "error 20" once that root is removed
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
typeset -A probeLabels probeFingerprints probePEMs
probeLabels=(
emSign "emSign Root TLS CA - G3 <- emSign ECC Root CA - G3"
AAA "EM DV TLS CA - G3B <- AAA Certificate Services"
)
probeFingerprints=(
emSign 3094025792039A68AF6E553686410BDAB7C0D4A36B9084CBA64202E35D6EBBDD
AAA 45A883F349AEDFD5F3A649B5498FFB121712BA8B448F3F2A91DBC5A766FCDDBE
)
# Served by the advisory's test host (crt.sh ID 14658191778)
probePEMs[emSign]='-----BEGIN CERTIFICATE-----
MIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw
CQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy
YSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290
IENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD
VQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg
MB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr
gQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m
WO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw
LKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj
QjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB
BQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB
FhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi
BggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4
aHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290
Q0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t
P1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB
ATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg
TroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0
1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV
-----END CERTIFICATE-----'
# Cross-signed by AAA Certificate Services (crt.sh ID 18519019583)
probePEMs[AAA]='-----BEGIN CERTIFICATE-----
MIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw
ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV
BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y
ODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW
MRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA
IgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO
L4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2
PM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd
BgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG
A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC
MDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h
AQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB
QUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF
BQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB
AQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE
wqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM
QkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ
7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl
bN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V
GUyRCEIDhF5TBUuKF4E3kf1h
-----END CERTIFICATE-----'
####################################################################################################
#
# Functions
#
####################################################################################################
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Client-side Logging (stdout only when scriptLog isn't writable, e.g., --check without sudo)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function updateScriptLog() {
if [[ -w "${scriptLog}" ]]; then
echo "${organizationScriptName} ($scriptVersion): $( date +%Y-%m-%d\ %H:%M:%S ) - ${1}" | tee -a "${scriptLog}"
else
echo "${organizationScriptName} ($scriptVersion): $( date +%Y-%m-%d\ %H:%M:%S ) - ${1}"
fi
}
function preFlight() {
updateScriptLog "[PRE-FLIGHT] ${1}"
}
function logComment() {
updateScriptLog " ${1}"
}
function notice() {
updateScriptLog "[NOTICE] ${1}"
}
function info() {
updateScriptLog "[INFO] ${1}"
}
function errorOut(){
updateScriptLog "[ERROR] ${1}"
}
function warning() {
updateScriptLog "[WARNING] ${1}"
let errorCount++
}
# Exits 2 (not 1); exit 1 is reserved for "still exposed"
function fatal() {
updateScriptLog "[FATAL ERROR] ${1}"
exit 2
}
function quitOut(){
updateScriptLog "[QUIT] ${1}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Exit with code (Parameter 1) and summary (Parameter 2)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function exitWith() {
info "Elapsed Time: $(printf '%dh:%dm:%ds\n' $((SECONDS/3600)) $((SECONDS%3600/60)) $((SECONDS%60)))"
quitOut "${2} (exit ${1})"
exit "${1}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Usage
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function usage() {
cat <<EOF
Usage: ${ZSH_ARGZERO:t} [--fix | --check | --dry-run | --restore [backup]] [--offline] | --help
--fix (default) Remove the two roots from ${certFile} (requires root)
--check Report status only; no root, no changes
--dry-run Build and verify a cleaned copy; install nothing
--restore [backup] Restore the newest (or specified) ${certFile}.bak.* (requires root)
--offline Skip the network test (local runs include it by default)
Jamf Pro:
Parameter 4 Operation Mode [ fix (default) | check | dry-run | restore ]
Parameter 5 Backup to restore (optional; defaults to newest)
Parameter 6 Network Test [ off (default) | on ]
Parameter 7 Test URL (optional; defaults to ${defaultTestURL})
Exit codes: 0 = safe / fixed; 1 = still exposed; 2 = couldn't tell / refused
EOF
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Remove work directory and any staged install file
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function cleanUp() {
[[ -n "${workDir}" && -d "${workDir}" ]] && rm -Rf "${workDir}"
[[ -n "${stagedFile}" && -f "${stagedFile}" ]] && rm -f "${stagedFile}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Split every PEM certificate block in a file (Parameter 1) into its own file in a directory
# (Parameter 2)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function splitPEM() {
local pemFile="${1}"
local splitDir="${2}"
rm -Rf "${splitDir}"
mkdir -p "${splitDir}" || return 2
awk -v dir="${splitDir}" '
/^-----BEGIN CERTIFICATE-----/ { n++; inBlock=1; file=sprintf("%s/%04d.pem", dir, n) }
inBlock { print > file }
/^-----END CERTIFICATE-----/ { if (inBlock) close(file); inBlock=0 }
' "${pemFile}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# SHA-256 fingerprint (uppercase hex, no colons) of a single-certificate PEM file (Parameter 1)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function certFingerprint() {
local fingerprint
fingerprint=$( openssl x509 -noout -fingerprint -sha256 -in "${1}" 2>/dev/null ) || return 2
fingerprint="${${fingerprint#*=}//:/}"
fingerprint="${(U)fingerprint}"
[[ "${fingerprint}" =~ ^[0-9A-F]{64}$ ]] || return 2
echo "${fingerprint}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Write sorted SHA-256 fingerprints of every parsed certificate in a PEM bundle (Parameter 1) to an
# output file (Parameter 2); returns 2 if any block fails to parse or the bundle holds none
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function listFingerprints() {
local pemFile="${1}"
local outFile="${2}"
local splitDir="${workDir}/split.${outFile:t}"
local block
# Fingerprint the PEM data itself, not the "===" header text
splitPEM "${pemFile}" "${splitDir}" || return 2
: > "${outFile}"
for block in "${splitDir}"/*.pem(N); do
certFingerprint "${block}" >> "${outFile}" || return 2
done
[[ -s "${outFile}" ]] || return 2
sort -o "${outFile}" "${outFile}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Bundle status of a PEM file (Parameter 1), fingerprints written to Parameter 2
# Sets: bundleState (clean | exposed | damaged), bundleCount, targetsPresent
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function bundleStatus() {
local pemFile="${1}"
local fingerprintFile="${2}"
local fingerprint
if ! listFingerprints "${pemFile}" "${fingerprintFile}"; then
bundleState="damaged"
errorOut "${pemFile} contains no certificates or a certificate that does not parse"
return 2
fi
bundleCount=$( wc -l < "${fingerprintFile}" | tr -d ' ' )
targetsPresent=0
for fingerprint in ${(k)targetRoots}; do
if grep -qxF "${fingerprint}" "${fingerprintFile}"; then
logComment "Present: ${targetRoots[$fingerprint]} (SHA-256 ${fingerprint})"
(( targetsPresent += 1 ))
fi
done
if (( targetsPresent > 0 )); then
bundleState="exposed"
else
bundleState="clean"
fi
logComment "${pemFile}: ${bundleCount} certificates; ${targetsPresent} of ${#targetRoots} target roots present"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Write the embedded offline probes to workDir and confirm each matches its pinned fingerprint
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function writeProbes() {
local probe fingerprint
for probe in ${(k)probeFingerprints}; do
print -r -- "${probePEMs[$probe]}" > "${workDir}/probe.${probe}.pem"
fingerprint=$( certFingerprint "${workDir}/probe.${probe}.pem" )
if [[ "${fingerprint}" != "${probeFingerprints[$probe]}" ]]; then
errorOut "Embedded probe '${probeLabels[$probe]}' does not match its pinned SHA-256"
return 2
fi
done
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Offline verification of a bundle (Parameter 1) against the embedded leaked intermediates
# Sets: offlineResult (trusted | refused | inconclusive)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function offlineVerify() {
local bundle="${1}"
local probe verifyOutput verifyRC refusal
local probesTrusted=0
local probesRefused=0
for probe in ${(k)probeFingerprints}; do
verifyOutput=$( openssl verify -attime "${verifyEpoch}" -CAfile "${bundle}" "${workDir}/probe.${probe}.pem" 2>&1 )
verifyRC=$?
# "error 20" / "error 2": no path from the probe to a trust anchor in the bundle
refusal=$( print -r -- "${verifyOutput}" | grep -m1 -E '^error (2|20) at ' )
if [[ ${verifyRC} -eq 0 && "${verifyOutput}" == *": OK" ]]; then
(( probesTrusted += 1 ))
logComment "Offline: ${probeLabels[$probe]}: verifies (exposed)"
elif [[ -n "${refusal}" ]]; then
(( probesRefused += 1 ))
logComment "Offline: ${probeLabels[$probe]}: refused (${refusal})"
else
logComment "Offline: ${probeLabels[$probe]}: unexpected result (exit ${verifyRC}): ${verifyOutput//$'\n'/ | }"
fi
done
if (( probesTrusted > 0 )); then
offlineResult="trusted"
elif (( probesRefused == ${#probeFingerprints} )); then
offlineResult="refused"
else
offlineResult="inconclusive"
fi
logComment "Offline verdict for ${bundle}: ${offlineResult}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# curl trust test of a URL (Parameter 1), optionally against a CA file (Parameter 2)
# Sets: trustRC, trustResult (trusted | refused | inconclusive)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function trustTest() {
local url="${1}"
local caFile="${2}"
local -a curlOptions=( -q -s -o /dev/null --max-time "${curlTimeout}" )
[[ -n "${caFile}" ]] && curlOptions+=( --cacert "${caFile}" )
/usr/bin/curl "${curlOptions[@]}" "${url}"
trustRC=$?
case "${trustRC}" in
0 ) trustResult="trusted" ;;
60 ) trustResult="refused" ;;
* ) trustResult="inconclusive" ;;
esac
logComment "curl ${url}${caFile:+ (--cacert ${caFile})}: exit ${trustRC} (${trustResult})"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Inspect the chain a host serves (Parameter 1: host:port; Parameter 2: server name); call only
# after curl completed a TLS handshake with that host, since s_client has no timeout of its own
# Sets: servedChainPinned (yes | no), servedIssuer (issuer of the served leaf)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function inspectServedChain() {
local connectTo="${1}"
local serverName="${2}"
local chainDir="${workDir}/served.${serverName}"
local block fingerprint
servedChainPinned="no"
servedIssuer=""
openssl s_client -connect "${connectTo}" -servername "${serverName}" -showcerts < /dev/null > "${chainDir}.txt" 2>/dev/null
splitPEM "${chainDir}.txt" "${chainDir}"
for block in "${chainDir}"/*.pem(N); do
[[ -z "${servedIssuer}" ]] && servedIssuer=$( openssl x509 -noout -issuer -in "${block}" 2>/dev/null )
fingerprint=$( certFingerprint "${block}" ) || continue
# (Ie): index of an exact match, or 0
(( ${pinnedChainFingerprints[(Ie)${fingerprint}]} )) && servedChainPinned="yes"
done
servedIssuer="${servedIssuer#issuer=}"
servedIssuer="${servedIssuer# }"
logComment "Served chain from ${serverName}: pinned leaked intermediate: ${servedChainPinned}; leaf issuer: ${servedIssuer:-none}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Network test of the Test URL, optionally against a CA file (Parameter 1); confirmation only
# Sets: networkResult (trusted | refused | invalid | unusable)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function networkProbe() {
local caFile="${1}"
trustTest "${testURL}" "${caFile}"
if [[ "${trustResult}" == "inconclusive" ]]; then
networkResult="unusable"
warning "Network test unusable (curl exit ${trustRC}); verdict rests on offline verification"
else
inspectServedChain "${testConnect}" "${testServerName}"
if [[ "${servedChainPinned}" == "yes" ]]; then
networkResult="${trustResult}"
else
networkResult="invalid"
warning "Served chain lacks the pinned leaked intermediates (TLS interception or test host change suspected); network result ignored"
fi
fi
logComment "Network verdict${caFile:+ (--cacert ${caFile})}: ${networkResult}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Final verdict for an unchanged or newly installed certFile, after bundleStatus, offlineVerify and
# (optionally) networkProbe; Parameter 1 is the summary when safe
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function reportVerdict() {
if [[ "${bundleState}" == "exposed" ]]; then
exitWith 1 "EXPOSED: ${targetsPresent} target root(s) present in ${certFile}"
elif [[ "${offlineResult}" == "trusted" ]]; then
exitWith 1 "EXPOSED: target roots absent, but a leaked intermediate still verifies against ${certFile}"
elif [[ "${offlineResult}" != "refused" ]]; then
exitWith 2 "Offline verification inconclusive; couldn't tell"
elif [[ "${networkResult}" == "trusted" ]]; then
exitWith 2 "Contradiction: leaked chain refused offline, but curl trusts ${testServerName} (curl may not be using ${certFile})"
else
exitWith 0 "${1} (offline: refused; network: ${networkResult})"
fi
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Fallback removal: copy every PEM block of a bundle (Parameter 1) to an output file (Parameter 2),
# except blocks whose SHA-256 is a target root; header and comment lines are dropped
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function removeByFingerprint() {
local pemFile="${1}"
local outFile="${2}"
local splitDir="${workDir}/split.fallback"
local block fingerprint
splitPEM "${pemFile}" "${splitDir}" || return 2
: > "${outFile}"
for block in "${splitDir}"/*.pem(N); do
fingerprint=$( certFingerprint "${block}" ) || return 2
# (+): 1 when the key exists
(( ${+targetRoots[$fingerprint]} )) && continue
cat "${block}" >> "${outFile}" || return 2
done
[[ -s "${outFile}" ]] || return 2
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Build cleaned bundle with the advisory awk (falling back to removeByFingerprint) and confirm it
# equals the original minus only the target roots
# Requires: bundleStatus of certFile written to ${workDir}/original.txt
# Sets: candidateFile
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function prepareCandidate() {
local originalFingerprints="${workDir}/original.txt"
local expectedFingerprints="${workDir}/expected.txt"
local targetList="${workDir}/targets.txt"
candidateFile="${workDir}/cert.pem.cleaned"
# Expected = original minus every target root
print -l -- ${(k)targetRoots} > "${targetList}"
grep -vxF -f "${targetList}" "${originalFingerprints}" > "${expectedFingerprints}"
notice "Build cleaned bundle"
if ! awk "${removalAwk}" "${certFile}" > "${candidateFile}"; then
errorOut "awk removal failed"
elif validateCandidate; then
return 0
fi
notice "Advisory awk did not validate; rebuilding by fingerprint"
if ! removeByFingerprint "${certFile}" "${candidateFile}"; then
errorOut "Fingerprint removal failed"
return 2
fi
validateCandidate || return 2
logComment "Cleaned bundle rebuilt by fingerprint (header and comment lines dropped)"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Confirm candidateFile equals the original minus only the target roots
# Requires: ${workDir}/expected.txt and ${workDir}/targets.txt from prepareCandidate
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function validateCandidate() {
local candidateFingerprints="${workDir}/candidate.txt"
local expectedFingerprints="${workDir}/expected.txt"
local targetList="${workDir}/targets.txt"
local candidateCount expectedCount
if ! listFingerprints "${candidateFile}" "${candidateFingerprints}"; then
errorOut "Cleaned bundle contains no certificates or a certificate that does not parse"
return 2
fi
candidateCount=$( wc -l < "${candidateFingerprints}" | tr -d ' ' )
expectedCount=$(( bundleCount - targetsPresent ))
logComment "Cleaned bundle: ${candidateCount} certificates (expected ${expectedCount})"
if grep -qxF -f "${targetList}" "${candidateFingerprints}"; then
errorOut "A target root is still in the cleaned bundle"
return 2
fi
if (( candidateCount != expectedCount )); then
errorOut "Certificate count mismatch"
return 2
fi
if ! cmp -s "${expectedFingerprints}" "${candidateFingerprints}"; then
errorOut "Cleaned bundle removed or changed certificates other than the target roots"
return 2
fi
logComment "Cleaned bundle validated: only the target roots were removed"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Offline verification of the cleaned bundle; exits unless it refuses the leaked chain
# Parameter 1: wording for the outcome (e.g., "no changes made" or "fix would refuse")
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function verifyCandidate() {
notice "Verify cleaned bundle offline"
offlineVerify "${candidateFile}"
case "${offlineResult}" in
trusted ) exitWith 1 "Cleaned bundle still verifies a leaked intermediate; ${1}" ;;
inconclusive ) exitWith 2 "Offline verification of cleaned bundle inconclusive; ${1}" ;;
esac
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Network test of the cleaned bundle (--cacert) before installing it; confirmation only
# Parameter 1: wording for the outcome (e.g., "no changes made" or "fix would refuse")
# Sets: candidateVerdict (confirmed | unconfirmed | network-unusable)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function testCandidate() {
notice "Network test: cleaned bundle"
networkProbe "${candidateFile}"
trustTest "${controlURL}" "${candidateFile}"
if [[ "${trustResult}" != "trusted" ]]; then
# Control failed: cleaned bundle broke normal TLS, or the network is unusable / intercepted
trustTest "${controlURL}" "${certFile}"
if [[ "${trustResult}" == "trusted" ]]; then
exitWith 2 "Cleaned bundle fails ${controlURL} but the original passes; ${1}"
fi
candidateVerdict="network-unusable"
[[ "${trustResult}" == "refused" ]] && inspectServedChain "${controlHost}:443" "${controlHost}"
elif [[ "${networkResult}" == "trusted" ]]; then
exitWith 2 "Contradiction: cleaned bundle refuses the leaked chain offline, but curl trusts ${testServerName} with it; ${1}"
elif [[ "${networkResult}" == "refused" ]]; then
candidateVerdict="confirmed"
else
candidateVerdict="unconfirmed"
fi
logComment "Cleaned bundle network verdict: ${candidateVerdict}"
[[ "${candidateVerdict}" != "confirmed" ]] && warning "Network confirmation unavailable (${candidateVerdict}); relying on offline verification"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Timestamped, verified backup of certFile; never overwrites
# Sets: backupFile
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function backupOriginal() {
backupFile="${certFile}.bak.$( date +%Y%m%d-%H%M%S )"
if [[ -e "${backupFile}" ]]; then
errorOut "Backup ${backupFile} already exists; refusing to overwrite"
return 2
fi
if ! cp -p "${certFile}" "${backupFile}" || ! cmp -s "${certFile}" "${backupFile}"; then
errorOut "Unable to create verified backup ${backupFile}"
return 2
fi
logComment "Backup: ${backupFile}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Atomically install a file (Parameter 1) as certFile: stage in certDir (same filesystem), set
# 644 root:wheel, then rename over certFile
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function installFile() {
local sourceFile="${1}"
stagedFile=$( mktemp "${certDir}/.cert.pem.XXXXXX" ) || return 2
if cp "${sourceFile}" "${stagedFile}" \
&& chmod 644 "${stagedFile}" \
&& chown root:wheel "${stagedFile}" \
&& mv -f "${stagedFile}" "${certFile}"; then
stagedFile=""
else
errorOut "Unable to install ${sourceFile} as ${certFile}"
return 2
fi
if ! cmp -s "${sourceFile}" "${certFile}"; then
errorOut "${certFile} does not match ${sourceFile} after install"
return 2
fi
logComment "Installed: $( stat -f '%Sp %Su:%Sg' "${certFile}" ) ${certFile}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Roll back certFile to backupFile
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function rollBack() {
warning "Rolling back to ${backupFile}"
if installFile "${backupFile}"; then
logComment "Rolled back; ${certFile} matches ${backupFile}"
else
errorOut "ROLLBACK FAILED; restore manually: sudo cp -p '${backupFile}' '${certFile}'"
fi
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Mode: --check (read-only)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function checkMode() {
notice "Check: ${certFile}"
bundleStatus "${certFile}" "${workDir}/original.txt" || exitWith 2 "${certFile} is damaged; couldn't tell"
offlineVerify "${certFile}"
if [[ "${networkTest}" == "on" ]]; then
notice "Network test: system default trust"
networkProbe
fi
reportVerdict "SAFE: target roots absent and leaked chain refused"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Mode: --dry-run (build and verify a cleaned copy in workDir; install nothing)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function dryRunMode() {
local originalHash
notice "Dry run: ${certFile} will not be changed"
originalHash=$( shasum -a 256 "${certFile}" | awk '{ print $1 }' )
bundleStatus "${certFile}" "${workDir}/original.txt" || exitWith 2 "${certFile} is damaged; would refuse to touch it"
offlineVerify "${certFile}"
if [[ "${bundleState}" == "clean" ]]; then
if [[ "${networkTest}" == "on" ]]; then
notice "Network test: system default trust"
networkProbe
fi
reportVerdict "Nothing to remove; ${certFile} is already clean"
fi
prepareCandidate || exitWith 2 "Cleaned bundle failed validation; fix would refuse"
verifyCandidate "fix would refuse"
[[ "${networkTest}" == "on" ]] && testCandidate "fix would refuse"
if [[ "$( shasum -a 256 "${certFile}" | awk '{ print $1 }' )" == "${originalHash}" ]]; then
logComment "${certFile} unchanged (SHA-256 ${originalHash})"
else
warning "${certFile} changed during the dry run (not by this script)"
fi
exitWith 0 "Dry run complete: ${bundleCount} -> $(( bundleCount - targetsPresent )) certificates; leaked chain refused offline (network: ${networkResult})"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Mode: fix (default)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function fixMode() {
notice "Fix: ${certFile}"
bundleStatus "${certFile}" "${workDir}/original.txt" || exitWith 2 "${certFile} is damaged; refusing to touch it"
offlineVerify "${certFile}"
if [[ "${bundleState}" == "clean" ]]; then
if [[ "${networkTest}" == "on" ]]; then
notice "Network test: system default trust"
networkProbe
fi
reportVerdict "Already clean; no changes made"
fi
prepareCandidate || exitWith 2 "Cleaned bundle failed validation; no changes made"
verifyCandidate "no changes made"
[[ "${networkTest}" == "on" ]] && testCandidate "no changes made"
notice "Install cleaned bundle"
backupOriginal || exitWith 2 "No verified backup; no changes made"
if ! installFile "${candidateFile}"; then
rollBack
exitWith 2 "Install failed; rolled back to original"
fi
notice "Verify installed bundle offline"
if ! bundleStatus "${certFile}" "${workDir}/installed.txt"; then
rollBack
exitWith 2 "Installed bundle does not parse; rolled back to original"
fi
offlineVerify "${certFile}"
if [[ "${bundleState}" == "exposed" || "${offlineResult}" == "trusted" ]]; then
rollBack
exitWith 1 "STILL EXPOSED after fix (offline); rolled back to original"
fi
if [[ "${networkTest}" == "on" ]]; then
notice "Network test: system default trust"
networkProbe
fi
reportVerdict "FIXED: backup: ${backupFile}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Mode: --restore [backup]
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function restoreMode() {
local -a backups
if [[ -n "${restoreSource}" ]]; then
backupFile="${restoreSource}"
else
# Newest by name; "cp -p" keeps the original's mtime, so mtime can't be used
backups=( "${certFile}".bak.*(N.On) )
backupFile="${backups[1]}"
fi
[[ -n "${backupFile}" && -f "${backupFile}" && ! -L "${backupFile}" ]] || exitWith 2 "No backup found to restore"
notice "Restore: ${backupFile}"
bundleStatus "${backupFile}" "${workDir}/backup.txt" || exitWith 2 "${backupFile} is damaged; refusing to restore it"
if [[ -f "${certFile}" ]] && cmp -s "${backupFile}" "${certFile}"; then
exitWith 0 "${certFile} already matches ${backupFile}; no changes made"
fi
installFile "${backupFile}" || exitWith 2 "Restore failed"
offlineVerify "${certFile}"
if [[ "${networkTest}" == "on" ]]; then
notice "Network test: system default trust"
networkProbe
fi
exitWith 0 "Restored ${certFile} from ${backupFile} (offline: ${offlineResult}; network: ${networkResult})"
}
####################################################################################################
#
# Pre-flight Checks
#
####################################################################################################
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Parameters
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Local runs pass no arguments or an option (e.g., --check) first; Jamf Pro runs pass Parameters 1–3
# (mount point, computer name, username), so the Operation Mode comes from Parameter 4
if [[ $# -eq 0 || "${1}" == -* ]]; then
if [[ $# -eq 0 ]]; then
parameterSource="local (default)"
else
parameterSource="local option"
fi
operationMode="${1:-"--fix"}"
operationMode="${(L)operationMode#--}"
restoreSource=""
networkTest="on"
testURL="${defaultTestURL}"
(( $# > 0 )) && shift
for localArgument in "$@"; do
if [[ "${localArgument}" == "--offline" ]]; then
networkTest="off"
elif [[ "${operationMode}" == "restore" && -z "${restoreSource}" && "${localArgument}" != -* ]]; then
restoreSource="${localArgument}"
else
usage >&2
exit 2
fi
done
elif [[ -n "${4}" ]]; then
parameterSource="Jamf Pro Parameter 4"
else
parameterSource="Jamf Pro (default)"
fi
# Accept "check", "Check" or "--check" from either source
operationMode="${(L)operationMode//[[:space:]]/}"
operationMode="${operationMode#--}"
networkTest="${(L)networkTest//[[:space:]]/}"
case "${operationMode}" in
fix | check | dry-run | restore ) ;;
-h | help ) usage; exit 0 ;;
* ) echo "Invalid Operation Mode: '${operationMode}'" >&2; usage >&2; exit 2 ;;
esac
case "${networkTest}" in
on | off ) ;;
* ) echo "Invalid Network Test: '${networkTest}'" >&2; usage >&2; exit 2 ;;
esac
# Test URL: https only; its host (and optional port) drive the served-chain inspection
if [[ "${testURL}" != https://?* ]]; then
echo "Invalid Test URL: '${testURL}'" >&2
usage >&2
exit 2
fi
testHost="${${testURL#https://}%%/*}"
testServerName="${testHost%%:*}"
if [[ "${testHost}" == *:* ]]; then
testConnect="${testHost}"
else
testConnect="${testHost}:443"
fi
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Client-side Logging
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
if [[ ! -f "${scriptLog}" && $(id -u) -eq 0 ]]; then
touch "${scriptLog}"
if [[ -f "${scriptLog}" ]]; then
preFlight "Created specified scriptLog: ${scriptLog}"
else
fatal "Unable to create specified scriptLog '${scriptLog}'; exiting."
fi
elif [[ -w "${scriptLog}" ]]; then
preFlight "Specified scriptLog '${scriptLog}' exists; writing log entries to it"
else
preFlight "Specified scriptLog '${scriptLog}' not writable; logging to stdout only"
fi
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Logging Preamble
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
preFlight "\n\n###\n# $humanReadableScriptName (${scriptVersion})\n###\n"
preFlight "Initiating …"
preFlight "Operation Mode: ${operationMode} (${parameterSource})"
if [[ "${networkTest}" == "on" ]]; then
preFlight "Network Test: on (${testURL}; control ${controlURL})"
else
preFlight "Network Test: off"
fi
preFlight "macOS $( sw_vers -productVersion ) ($( sw_vers -buildVersion ))"
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Confirm script is running as root (fix and restore only)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
if [[ "${operationMode}" == "fix" || "${operationMode}" == "restore" ]] && [[ $(id -u) -ne 0 ]]; then
fatal "Mode '${operationMode}' must be run as root; exiting."
fi
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: certFile is a regular file, not a symlink (restore may recreate a missing one)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
if [[ -L "${certFile}" ]]; then
fatal "${certFile} is a symlink; refusing to continue."
elif [[ "${operationMode}" != "restore" ]] && [[ ! -f "${certFile}" || ! -r "${certFile}" ]]; then
fatal "${certFile} is missing or unreadable; refusing to continue."
fi
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Required tools
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
command -v openssl > /dev/null 2>&1 || fatal "openssl not found; exiting."
if [[ "${networkTest}" == "on" && ! -x /usr/bin/curl ]]; then
fatal "/usr/bin/curl not found; exiting."
fi
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Work Directory and Offline Probes
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
workDir=$( mktemp -d -t "${organizationScriptName}" ) || fatal "Unable to create work directory; exiting."
trap cleanUp EXIT
writeProbes || fatal "Embedded offline probes failed verification; exiting."
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Complete
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
preFlight "Complete!"
####################################################################################################
#
# Program
#
####################################################################################################
case "${operationMode}" in
check ) checkMode ;;
dry-run ) dryRunMode ;;
restore ) restoreMode ;;
* ) fixMode ;;
esac
# Every mode exits via exitWith; reaching here is unexpected
exitWith 2 "Unexpected end of script"
Bonus: Remediate pip eMudhra
Apple’s built-in
pip3(from the Command Line Tools) still trusts several eMudhra certificate keys leaked in October 2026 (because it uses its own outdated list of trusted certificates).This policy points Apple’s
pip3, and any virtual environments created from Apple’spython3, at the macOS system certificate file (/etc/ssl/cert.pem).
- Run Remediate cert.pem eMudhra first; this fix only helps once that file is clean.
#!/bin/zsh --no-rcs
# shellcheck shell=bash
####################################################################################################
#
# Remediate pip eMudhra
#
# Purpose: Points the pip of Apple's developer-tools python3 at /etc/ssl/cert.pem via PIP_CERT;
# that pip verifies against its own vendored certifi bundle, which still holds the two
# roots the leaked eMudhra intermediates chain to
#
# Usage: Jamf Pro: Parameter 4: Operation Mode [ fix (default) | check | dry-run | restore ]
# Parameter 5: Include Xcode.app [ off (default) | on ]
#
# Local: sudo zsh Remediate-pip-eMudhra.zsh # Fix (default)
# zsh Remediate-pip-eMudhra.zsh --check # Read-only status
# zsh Remediate-pip-eMudhra.zsh --dry-run # Build and verify; install nothing
# sudo zsh Remediate-pip-eMudhra.zsh --restore # Remove this script's block
# Append --include-xcode to any local option to include Xcode.app's python3
#
# Exit: 0 = safe / fixed (or no Apple python3); 1 = still exposed; 2 = couldn't tell / refused
#
# Notes:
# - Advisory: https://emudhra.we-have-a-problem.com/#macos-curl
# - Run Remediate-cert.pem-eMudhra.zsh first: PIP_CERT only helps once /etc/ssl/cert.pem is clean
# - Adds a marker-delimited block to sitecustomize.py in each python3's stdlib, so venvs made from
# that python3 are covered too; setdefault() keeps any PIP_CERT already set
# - Command Line Tools and Xcode updates replace the stdlib; re-run after each update (macOS
# updates restore cert.pem instead)
# - Python3.framework's code seal covers its stdlib, so "codesign --verify" fails once
# sitecustomize.py is added; python3 still runs, since its Mach-O signatures are untouched
# - Xcode.app is off by default: App Management may block writes inside it, and the change breaks
# Xcode.app's own seal
# - Not pip.conf: a global "cert =" would also reach pips that use truststore (the macOS
# keychain) and drop keychain-only roots, e.g., a TLS decryption CA
# - Out of scope: pip from Homebrew, python.org or pyenv, and code that reads certifi directly
#
####################################################################################################
#
# HISTORY
#
# Version 0.0.1, 21-Oct-2026, Dan K. Snelson (@dan-snelson)
# - Original, proof-of-concept version
#
####################################################################################################
####################################################################################################
#
# Global Variables
#
####################################################################################################
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
# Script Version
scriptVersion="0.0.1"
# Client-side Log
scriptLog="/var/log/org.churchofjesuschrist.log"
# Elapsed Time
SECONDS="0"
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Jamf Pro Script Parameters
# (Parameters 1–3 are reserved by Jamf Pro: mount point, computer name and username; local runs
# instead pass no arguments or an option such as --check; see "Pre-flight Check: Parameters")
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Parameter 4: Operation Mode [ fix (default) | check | dry-run | restore ]
operationMode="${4:-"fix"}"
# Parameter 5: Include Xcode.app [ off (default) | on ]; "on" adds the python3 of the active Xcode.app
includeXcode="${5:-"off"}"
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Organization Variables
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Script Human-readabale Name
humanReadableScriptName="Remediate pip eMudhra"
# Organization's Script Name
organizationScriptName="RPPE"
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Python Variables
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# The bundle PIP_CERT points pip at (cleaned by Remediate-cert.pem-eMudhra.zsh)
certFile="/etc/ssl/cert.pem"
# Command Line Tools python3 (always included); Xcode.app's comes from "xcode-select -p"
cltPython="/Library/Developer/CommandLineTools/usr/bin/python3"
# sitecustomize.py block; the BEGIN and END lines are matched exactly
blockBegin="# BEGIN ${organizationScriptName} PIP_CERT (eMudhra CA compromise, Oct 2026)"
blockBody="import os; os.environ.setdefault(\"PIP_CERT\", \"${certFile}\")"
blockEnd="# END ${organizationScriptName} PIP_CERT"
# SHA-256 fingerprints of the roots pip must no longer trust (uppercase hex, no colons)
typeset -A targetRoots
targetRoots=(
D7A7A0FB5D7E2731D771E9484EBCDEF71D5F0C3E0A2948782BC83EE0EA699EF4 "AAA Certificate Services"
86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B "emSign ECC Root CA - G3"
)
# Per-mode tallies and candidate file counter
exposedCount="0"
unknownCount="0"
candidateCount="0"
####################################################################################################
#
# Functions
#
####################################################################################################
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Client-side Logging (stdout only when scriptLog isn't writable, e.g., --check without sudo)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function updateScriptLog() {
if [[ -w "${scriptLog}" ]]; then
echo "${organizationScriptName} ($scriptVersion): $( date +%Y-%m-%d\ %H:%M:%S ) - ${1}" | tee -a "${scriptLog}"
else
echo "${organizationScriptName} ($scriptVersion): $( date +%Y-%m-%d\ %H:%M:%S ) - ${1}"
fi
}
function preFlight() {
updateScriptLog "[PRE-FLIGHT] ${1}"
}
function logComment() {
updateScriptLog " ${1}"
}
function notice() {
updateScriptLog "[NOTICE] ${1}"
}
function info() {
updateScriptLog "[INFO] ${1}"
}
function errorOut(){
updateScriptLog "[ERROR] ${1}"
}
function warning() {
updateScriptLog "[WARNING] ${1}"
let errorCount++
}
# Exits 2 (not 1); exit 1 is reserved for "still exposed"
function fatal() {
updateScriptLog "[FATAL ERROR] ${1}"
exit 2
}
function quitOut(){
updateScriptLog "[QUIT] ${1}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Exit with code (Parameter 1) and summary (Parameter 2)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function exitWith() {
info "Elapsed Time: $(printf '%dh:%dm:%ds\n' $((SECONDS/3600)) $((SECONDS%3600/60)) $((SECONDS%60)))"
quitOut "${2} (exit ${1})"
exit "${1}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Usage
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function usage() {
cat <<EOF
Usage: ${ZSH_ARGZERO:t} [--fix | --check | --dry-run | --restore] [--include-xcode] | --help
--fix (default) Add the PIP_CERT block to each python3's sitecustomize.py (requires root)
--check Report status only; no root, no changes
--dry-run Build and compile-check each patched sitecustomize.py; install nothing
--restore Remove this script's block (and an otherwise empty file) (requires root)
--include-xcode Also include the python3 of the active Xcode.app (off by default)
Jamf Pro:
Parameter 4 Operation Mode [ fix (default) | check | dry-run | restore ]
Parameter 5 Include Xcode.app [ off (default) | on ]
Exit codes: 0 = safe / fixed (or no Apple python3); 1 = still exposed; 2 = couldn't tell / refused
EOF
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Remove work directory and any staged install file
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function cleanUp() {
[[ -n "${workDir}" && -d "${workDir}" ]] && rm -Rf "${workDir}"
[[ -n "${stagedFile}" && -f "${stagedFile}" ]] && rm -f "${stagedFile}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Split every PEM certificate block in a file (Parameter 1) into its own file in a directory
# (Parameter 2)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function splitPEM() {
local pemFile="${1}"
local splitDir="${2}"
rm -Rf "${splitDir}"
mkdir -p "${splitDir}" || return 2
awk -v dir="${splitDir}" '
/^-----BEGIN CERTIFICATE-----/ { n++; inBlock=1; file=sprintf("%s/%04d.pem", dir, n) }
inBlock { print > file }
/^-----END CERTIFICATE-----/ { if (inBlock) close(file); inBlock=0 }
' "${pemFile}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# SHA-256 fingerprint (uppercase hex, no colons) of a single-certificate PEM file (Parameter 1)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function certFingerprint() {
local fingerprint
fingerprint=$( openssl x509 -noout -fingerprint -sha256 -in "${1}" 2>/dev/null ) || return 2
fingerprint="${${fingerprint#*=}//:/}"
fingerprint="${(U)fingerprint}"
[[ "${fingerprint}" =~ ^[0-9A-F]{64}$ ]] || return 2
echo "${fingerprint}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Count target roots in a PEM bundle (Parameter 1)
# Sets: targetsPresent; returns 2 if the bundle holds no certificates or one that does not parse
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function countTargets() {
local pemFile="${1}"
local splitDir="${workDir}/split"
local -a blocks
local block fingerprint
targetsPresent=0
[[ -f "${pemFile}" && -r "${pemFile}" ]] || return 2
splitPEM "${pemFile}" "${splitDir}" || return 2
blocks=( "${splitDir}"/*.pem(N) )
(( ${#blocks} > 0 )) || return 2
for block in "${blocks[@]}"; do
fingerprint=$( certFingerprint "${block}" ) || return 2
# (+): 1 when the key exists
(( ${+targetRoots[$fingerprint]} )) && (( targetsPresent += 1 ))
done
return 0
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Apple developer-tools python3 binaries to remediate
# Sets: pythons
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function discoverPythons() {
local developerDir xcodePython
pythons=()
[[ -x "${cltPython}" ]] && pythons+=( "${cltPython}" )
# "xcode-select -p" prints the Command Line Tools path (already included) or a *.app path
developerDir=$( xcode-select -p 2>/dev/null )
xcodePython="${developerDir}/usr/bin/python3"
if [[ "${developerDir}" == *.app/Contents/Developer && -x "${xcodePython}" ]]; then
if [[ "${includeXcode}" == "on" ]]; then
pythons+=( "${xcodePython}" )
else
logComment "Excluded (Include Xcode.app: off): ${xcodePython}"
fi
fi
logComment "python3 to remediate: ${#pythons} (${pythons[*]:-none})"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Inspect a python3 (Parameter 1)
# Sets: pyStdlib, pySiteCustomize, pyActiveSiteCustomize, pyVendoredBundle,
# pyBlockState (absent | present | partial), pyPipCert, pyVerdict (safe | exposed)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function inspectPython() {
local python="${1}"
local -a pythonInfo
local pipStatus
pyStdlib=""
pySiteCustomize=""
pyActiveSiteCustomize=""
pyVendoredBundle=""
pyBlockState="absent"
pyPipCert=""
pyVerdict="exposed"
# -I: ignore PYTHON* variables and the user's site-packages; -B: write no .pyc files
# Prints: stdlib, pip's vendored certifi bundle, the sitecustomize.py Python imports ("-" = none)
pythonInfo=( "${(@f)$( "${python}" -I -B -c '
import importlib.util, os, sysconfig
print(sysconfig.get_path("stdlib"))
pip = importlib.util.find_spec("pip")
print(os.path.join(os.path.dirname(pip.origin), "_vendor", "certifi", "cacert.pem") if pip and pip.origin else "-")
active = importlib.util.find_spec("sitecustomize")
print(active.origin if active and active.origin else "-")
' 2>/dev/null )}" )
pyStdlib="${pythonInfo[1]}"
if [[ -z "${pyStdlib}" || ! -d "${pyStdlib}" || -L "${pyStdlib}" ]]; then
errorOut "${python}: unable to determine its stdlib"
return 2
fi
# (:#-): empty when the value is "-"
pySiteCustomize="${pyStdlib}/sitecustomize.py"
pyVendoredBundle="${pythonInfo[2]:#-}"
pyActiveSiteCustomize="${pythonInfo[3]:#-}"
if [[ -L "${pySiteCustomize}" ]] || [[ -e "${pySiteCustomize}" && ! -f "${pySiteCustomize}" ]]; then
errorOut "${pySiteCustomize} is not a regular file"
return 2
fi
# "partial": a BEGIN or END line without its partner, a repeat, or the two out of order
if [[ -f "${pySiteCustomize}" ]]; then
pyBlockState=$( awk -v begin="${blockBegin}" -v end="${blockEnd}" '
$0 == begin { beginCount++; if (!beginLine) beginLine = NR }
$0 == end { endCount++; if (!endLine) endLine = NR }
END {
if (beginCount + endCount == 0) print "absent"
else if (beginCount == 1 && endCount == 1 && beginLine < endLine) print "present"
else print "partial"
}
' "${pySiteCustomize}" )
fi
# Clean environment: only sitecustomize.py can set PIP_CERT
pyPipCert=$( env -i "${python}" -I -B -c 'import os; print(os.environ.get("PIP_CERT", ""))' 2>/dev/null )
if [[ "${pyPipCert}" == "${certFile}" ]]; then
pyVerdict="safe"
pipStatus="uses ${certFile}"
elif [[ -z "${pyVendoredBundle}" ]]; then
pyVerdict="safe"
pipStatus="not installed"
elif ! countTargets "${pyVendoredBundle}"; then
pipStatus="vendored bundle missing or unreadable; assuming exposed"
elif (( targetsPresent > 0 )); then
pipStatus="vendored bundle holds ${targetsPresent} target root(s)"
else
pyVerdict="safe"
pipStatus="vendored bundle holds no target roots"
fi
logComment "${python}: block ${pyBlockState}; PIP_CERT '${pyPipCert:-unset}'; pip ${pipStatus}"
logComment "${python}: ${pyVerdict}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Compile-check a file (Parameter 2) with a python3 (Parameter 1); bytes honor any coding cookie
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function compileCheck() {
if ! "${1}" -I -S -B -c 'import sys; compile(open(sys.argv[1], "rb").read(), sys.argv[1], "exec")' "${2}" 2>/dev/null; then
errorOut "${2} does not compile with ${1}"
return 2
fi
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Build the patched sitecustomize.py for the inspected python3 (Parameter 1) in workDir
# Requires: inspectPython; Sets: candidateFile
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function prepareCandidate() {
local python="${1}"
case "${pyBlockState}" in
present ) errorOut "${python}: block present in ${pySiteCustomize}, but PIP_CERT is '${pyPipCert:-unset}'"; return 2 ;;
partial ) errorOut "${python}: incomplete block in ${pySiteCustomize}; fix it manually"; return 2 ;;
esac
# Only the first sitecustomize on sys.path is imported; stdlib's would hide one in site-packages
if [[ -n "${pyActiveSiteCustomize}" && "${pyActiveSiteCustomize}" != "${pySiteCustomize}" ]]; then
errorOut "${python}: ${pySiteCustomize} would shadow ${pyActiveSiteCustomize}; refusing"
return 2
fi
candidateFile="${workDir}/sitecustomize.$(( ++candidateCount )).py"
: > "${candidateFile}"
if [[ -s "${pySiteCustomize}" ]]; then
cat "${pySiteCustomize}" > "${candidateFile}" || return 2
# Start the block on a fresh line
[[ -n "$( tail -c1 "${candidateFile}" )" ]] && print >> "${candidateFile}"
fi
print -r -l -- "${blockBegin}" "${blockBody}" "${blockEnd}" >> "${candidateFile}" || return 2
compileCheck "${python}" "${candidateFile}" || return 2
logComment "${python}: patched ${pySiteCustomize:t} builds and compiles"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Atomically install a file (Parameter 1) as Parameter 2: stage in the same directory, set
# 644 root:wheel, then rename over the destination
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function installFile() {
local sourceFile="${1}"
local destinationFile="${2}"
stagedFile=$( mktemp "${destinationFile:h}/.${destinationFile:t}.XXXXXX" ) || return 2
if cp "${sourceFile}" "${stagedFile}" \
&& chmod 644 "${stagedFile}" \
&& chown root:wheel "${stagedFile}" \
&& mv -f "${stagedFile}" "${destinationFile}"; then
stagedFile=""
else
errorOut "Unable to install ${sourceFile} as ${destinationFile}"
return 2
fi
if ! cmp -s "${sourceFile}" "${destinationFile}"; then
errorOut "${destinationFile} does not match ${sourceFile} after install"
return 2
fi
logComment "Installed: $( stat -f '%Sp %Su:%Sg' "${destinationFile}" ) ${destinationFile}"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Remove this script's block from the inspected sitecustomize.py; remove the file (and its
# bytecode) when nothing else is left in it
# Requires: inspectPython with pyBlockState "present"
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function removeBlock() {
candidateFile="${workDir}/sitecustomize.$(( ++candidateCount )).py"
awk -v begin="${blockBegin}" -v end="${blockEnd}" '
$0 == begin { skip = 1 }
!skip
$0 == end { skip = 0 }
' "${pySiteCustomize}" > "${candidateFile}" || return 2
if grep -q '[^[:space:]]' "${candidateFile}"; then
installFile "${candidateFile}" "${pySiteCustomize}"
else
rm -f "${pySiteCustomize}" "${pySiteCustomize:h}"/__pycache__/sitecustomize.*.pyc(N) || return 2
logComment "Removed ${pySiteCustomize} (only this script's block was in it)"
fi
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Warn when the bundle PIP_CERT points at still holds a target root; never changes the verdict
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function checkCertFile() {
if ! countTargets "${certFile}"; then
warning "${certFile} is missing or does not parse; PIP_CERT points pip at it"
elif (( targetsPresent > 0 )); then
warning "${certFile} still holds ${targetsPresent} target root(s); run Remediate-cert.pem-eMudhra.zsh"
else
logComment "${certFile}: target roots absent"
fi
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Final verdict from the tallies; Parameter 1 is the summary when safe
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function reportVerdict() {
if (( exposedCount > 0 )); then
exitWith 1 "EXPOSED: pip of ${exposedCount} of ${#pythons} python3 still trusts the target roots"
elif (( unknownCount > 0 )); then
exitWith 2 "Couldn't tell or refused for ${unknownCount} of ${#pythons} python3"
else
exitWith 0 "${1}"
fi
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Mode: --check (read-only)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function checkMode() {
local python
for python in "${pythons[@]}"; do
notice "Check: ${python}"
if ! inspectPython "${python}"; then
(( unknownCount += 1 ))
elif [[ "${pyVerdict}" == "exposed" ]]; then
(( exposedCount += 1 ))
fi
done
checkCertFile
reportVerdict "SAFE: pip of ${#pythons} python3 uses ${certFile} or holds no target roots"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Mode: --dry-run (build and compile-check in workDir; install nothing)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function dryRunMode() {
local python
local wouldPatch=0
for python in "${pythons[@]}"; do
notice "Dry run: ${python}"
if ! inspectPython "${python}"; then
(( unknownCount += 1 ))
elif [[ "${pyVerdict}" == "safe" ]]; then
logComment "${python}: nothing to change"
elif prepareCandidate "${python}"; then
(( wouldPatch += 1 ))
logComment "${python}: fix would add the block to ${pySiteCustomize}"
else
(( unknownCount += 1 ))
fi
done
checkCertFile
if (( unknownCount > 0 )); then
exitWith 2 "Dry run: fix would refuse or couldn't tell for ${unknownCount} of ${#pythons} python3"
fi
exitWith 0 "Dry run complete: fix would patch ${wouldPatch} of ${#pythons} python3"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Mode: fix (default)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function fixMode() {
local python
local patched=0
for python in "${pythons[@]}"; do
notice "Fix: ${python}"
if ! inspectPython "${python}"; then
(( unknownCount += 1 ))
continue
fi
if [[ "${pyVerdict}" == "safe" ]]; then
logComment "${python}: no changes made"
continue
fi
if ! prepareCandidate "${python}"; then
(( unknownCount += 1 ))
continue
fi
if ! installFile "${candidateFile}" "${pySiteCustomize}"; then
(( unknownCount += 1 ))
continue
fi
notice "Verify: ${python}"
if inspectPython "${python}" && [[ "${pyVerdict}" == "safe" ]]; then
(( patched += 1 ))
continue
fi
warning "${python}: PIP_CERT not picked up; removing the block"
if [[ "${pyBlockState}" == "present" ]] && removeBlock; then
logComment "${python}: block removed; ${pySiteCustomize} back to its prior content"
else
errorOut "ROLLBACK FAILED; remove the ${organizationScriptName} PIP_CERT block from ${pySiteCustomize} manually"
fi
(( exposedCount += 1 ))
done
checkCertFile
reportVerdict "FIXED: patched ${patched} of ${#pythons} python3; the rest needed no change"
}
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Mode: --restore
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
function restoreMode() {
local python
local removed=0
for python in "${pythons[@]}"; do
notice "Restore: ${python}"
if ! inspectPython "${python}"; then
(( unknownCount += 1 ))
continue
fi
case "${pyBlockState}" in
absent )
logComment "${python}: no block in ${pySiteCustomize}; no changes made"
continue
;;
partial )
errorOut "${python}: incomplete block in ${pySiteCustomize}; refusing (fix it manually)"
(( unknownCount += 1 ))
continue
;;
esac
if removeBlock && inspectPython "${python}" && [[ "${pyBlockState}" == "absent" ]]; then
(( removed += 1 ))
else
errorOut "${python}: unable to remove the block from ${pySiteCustomize}"
(( unknownCount += 1 ))
fi
done
if (( unknownCount > 0 )); then
exitWith 2 "Restore incomplete for ${unknownCount} of ${#pythons} python3"
fi
exitWith 0 "Restored: removed the block from ${removed} of ${#pythons} python3"
}
####################################################################################################
#
# Pre-flight Checks
#
####################################################################################################
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Parameters
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Local runs pass no arguments or an option (e.g., --check) first; Jamf Pro runs pass Parameters 1–3
# (mount point, computer name, username), so the Operation Mode comes from Parameter 4
if [[ $# -eq 0 || "${1}" == -* ]]; then
if [[ $# -eq 0 ]]; then
parameterSource="local (default)"
else
parameterSource="local option"
fi
operationMode="${1:-"--fix"}"
operationMode="${(L)operationMode#--}"
includeXcode="off"
(( $# > 0 )) && shift
for localArgument in "$@"; do
if [[ "${localArgument}" == "--include-xcode" ]]; then
includeXcode="on"
else
usage >&2
exit 2
fi
done
elif [[ -n "${4}" ]]; then
parameterSource="Jamf Pro Parameter 4"
else
parameterSource="Jamf Pro (default)"
fi
# Accept "check", "Check" or "--check" from either source
operationMode="${(L)operationMode//[[:space:]]/}"
operationMode="${operationMode#--}"
includeXcode="${(L)includeXcode//[[:space:]]/}"
case "${operationMode}" in
fix | check | dry-run | restore ) ;;
-h | help ) usage; exit 0 ;;
* ) echo "Invalid Operation Mode: '${operationMode}'" >&2; usage >&2; exit 2 ;;
esac
case "${includeXcode}" in
on | off ) ;;
* ) echo "Invalid Include Xcode.app: '${includeXcode}'" >&2; usage >&2; exit 2 ;;
esac
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Client-side Logging
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
if [[ ! -f "${scriptLog}" && $(id -u) -eq 0 ]]; then
touch "${scriptLog}"
if [[ -f "${scriptLog}" ]]; then
preFlight "Created specified scriptLog: ${scriptLog}"
else
fatal "Unable to create specified scriptLog '${scriptLog}'; exiting."
fi
elif [[ -w "${scriptLog}" ]]; then
preFlight "Specified scriptLog '${scriptLog}' exists; writing log entries to it"
else
preFlight "Specified scriptLog '${scriptLog}' not writable; logging to stdout only"
fi
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Logging Preamble
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
preFlight "\n\n###\n# $humanReadableScriptName (${scriptVersion})\n###\n"
preFlight "Initiating …"
preFlight "Operation Mode: ${operationMode} (${parameterSource})"
preFlight "Include Xcode.app: ${includeXcode}"
preFlight "macOS $( sw_vers -productVersion ) ($( sw_vers -buildVersion ))"
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Confirm script is running as root (fix and restore only)
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
if [[ "${operationMode}" == "fix" || "${operationMode}" == "restore" ]] && [[ $(id -u) -ne 0 ]]; then
fatal "Mode '${operationMode}' must be run as root; exiting."
fi
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Required tools
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
command -v openssl > /dev/null 2>&1 || fatal "openssl not found; exiting."
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Work Directory
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
workDir=$( mktemp -d -t "${organizationScriptName}" ) || fatal "Unable to create work directory; exiting."
trap cleanUp EXIT
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Pre-flight Check: Complete
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
preFlight "Complete!"
####################################################################################################
#
# Program
#
####################################################################################################
notice "Discover Apple developer-tools python3"
discoverPythons
(( ${#pythons} > 0 )) || exitWith 0 "No Apple developer-tools python3 to remediate"
case "${operationMode}" in
check ) checkMode ;;
dry-run ) dryRunMode ;;
restore ) restoreMode ;;
* ) fixMode ;;
esac
# Every mode exits via exitWith; reaching here is unexpected
exitWith 2 "Unexpected end of script"
Resources
- Bugzilla 2083449: eMudhra: Private key compromise of Intermediate Certificates (Mozilla)
- eMudhra Statement on Recent Certificate Security Incident (eMudhra)
- Does the wrong key still turn? (Deimos AI LLC)
- eMudhra CA key compromise: find and fix the trust stores your browser doesn’t cover (Dhruv Majumdar)
- About the compromised eMudhra intermediate CAs (Apple)
- Conda Remediation (Per Kristian Thorsen)