Menu Close

Remediating eMudhra’s private key compromise with Jamf Pro

A Jamf Pro Extension Attribute and remediation script duo

Background

Apple’s block of the leaked eMudhra intermediates protects only Security.framework apps.

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-Passenger7430 post 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.pem is 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 (system curl, git, python3, openssl, perl, ruby, etc.), Safari and other Security.framework applications 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"

Posted in Jamf Pro, macOS, Scripts, Tips & Tricks

Related Posts

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.