A Jamf Pro Extension Attribute and remediation script duo

Background
Apple’s block of the leaked eMudhra intermediates protects only Security.framework apps.
- Bugzilla 2083449: eMudhra: Private key compromise of Intermediate Certificates
- eMudhra Statement on Recent Certificate Security Incident
Binaries such as curl, git, python3 and openssl still trust /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"