Skip to content

Mac Health Check (5.0.0)

Choose a tag to compare

@dan-snelson dan-snelson released this 04 Oct 21:54
· 1 commit to main since this release
v5.0.0
174b061

04-Oct-2026

  • Security: checkElectronCornerMask() no longer copies arbitrary file contents into user-readable output. A standard user could link ~/Applications/<App>.app/…/Electron Framework.framework/…/version to any root-only file (including MacHealthCheck-Secrets.plist), and root then echoed its contents into the dialog, the Inspect compliance plist, the client log and the Splunk report. To prevent this:
    • Symlinked app bundles, frameworks and version / version.txt files are skipped
    • version / version.txt files are read with a 64-byte cap
    • Every version value must be version-shaped (or custom-<commit>); anything else is reported as version unknown
    • Removed the name-based Visual Studio Code / Slack "known fixed" allowlist; both now report their actual Electron framework version
    • Rotate the Splunk HEC token and webhook URL on any macOS 26 (or later) Mac that ran a 5.0.0 beta with the secrets file in place
  • Security: checkExternalJamfPro() now treats Not Running as a failure; previously it matched the Running success pattern, so stopped Zscaler, Nessus and Splunk forwarder agents were reported as healthy
    • Each external-check jamf policy -event call is now limited to externalCheckTimeoutSeconds (default 120) and reports Timed Out instead of stalling the run
    • Sample external checks now print Failed: Not Running (Zscaler, Nessus, Splunk Universal Forwarder; Nessus Agent Status.sh no longer treats not running as running and reports Not Installed when the agent is absent), Failed: … / Running (Printer, Microsoft Office 365), and use #!/bin/bash instead of #!/usr/bin/env bash
    • Sophos Endpoint RTS.bash (0.0.2) now prints Failed: Real Time Scanning Disabled (previously Disabled, reported as an error), and CrowdStrike Falcon Status.bash reports a missing Falcon agent (No such file) as Failed: Not Installed instead of Not Installed
    • Splunk Universal Forwarder Check.sh (1.1.3) captures splunk status output before matching, so pipefail cannot report a running forwarder as Failed: Not Running
    • Check Printer Install.zsh now runs with --no-rcs and a fixed system PATH
    • TenableNessusAgent-Alternate.sh no longer lets Running: Yes overwrite an authentication-error or not-linked result, and reports Not Running (fail) when the agent is installed but stopped
    • CrowdStrike Falcon Status.bash (0.0.16) now restores (or removes) the system and root AppleLocale values on exit or termination instead of permanently setting en_US; locales changed by earlier versions are not restored automatically
    • Microsoft Defender Check.sh (0.0.3) now calls the first executable of Tools/mdatp or Tools/wdavdaemonclient inside /Applications/Microsoft Defender.app/Contents/Resources/ instead of user-writable /usr/local/bin/mdatp
    • Removed /usr/local/bin from PATH in the BeyondTrust (0.0.5), Cisco Umbrella (0.0.8), CrowdStrike Falcon, GlobalProtect (0.0.4), Nessus Agent, Splunk Universal Forwarder and Zscaler Tunnel external checks
  • Security: user-writable logs can no longer make health checks look healthy
    • checkAppAutoPatch() now supports App Auto-Patch 4.0.0, preferring root-written logs (4.x /Library/Application Support/AppAutoPatch/logs/aap.log, then 3.x /Library/Management/AppAutoPatch/logs/aap.log) and reading the per-user ~/Library/Logs/AppAutoPatch/aap.log only when neither exists (logged as user-reported); uses the newest Discovery complete timestamp, ignores timestamps more than five minutes in the future, and reports Unable to determine last run when a 4.x log has no Discovery complete entry
    • checkAvailableSoftwareUpdates() reads DDM enforcement from the root-written /var/db/softwareupdate/SoftwareUpdateDDMStatePersistence.plist (TargetOSVersion / TargetLocalDateTime; highest declared version wins), falling back to the install.log resolver (and its padded-date lookup) only when the plist is missing, untrusted or unrecognized; logs DDM Resolver: source=statePlist
    • checkAPNs() only counts log entries from processes under /System/ or /usr/libexec/
  • Security: reporting secrets now fail closed
    • Added an optional root-only secrets file, /Library/Management/org.churchofjesuschrist/MacHealthCheck-Secrets.plist (splunkHECToken, webhookURL; root:wheel mode 600), which keeps those secrets out of the process list; each run logs the secret source
    • A Splunk HEC token or webhook URL supplied only through Parameters 8 or 5 is rejected and logged as [ERROR] (Splunk HEC delivery and webhook messages are skipped); Silent + splunkOperationMode=production runs whose only HEC token is a rejected Parameter 8 value exit 1 before discovery
    • Source-level allowParameterSecrets="true" is a temporary, not-recommended legacy opt-in that accepts parameter secrets with a process-list [WARNING]
    • Corrected the README claim that parameters never appear in the process list
  • Security: report delivery
    • Splunk HEC and webhook curl calls use --proto =https --tlsv1.2; non-https:// URLs are refused and logged
    • Slack and Microsoft Teams webhook messages now use a shared sendWebhookPayload() sender matching the Splunk HEC pattern (--fail-with-body, --max-time 15, HTTP status capture, up to three attempts with backoff for HTTP 5xx or 000 only); rejected deliveries log a [WARNING] with the HTTP status and a short response excerpt instead of only the curl exit code; webhook failures do not change report status or exit codes
    • Parameter 6 splunkOperationMode now fails safe: only off, test or production (case-insensitive) are accepted, and any unrecognized value falls back to test with an [ERROR] log entry instead of silently enabling production Splunk HEC delivery
    • Test and Development runs now write MacHealthCheck-Report-<mode>.json instead of the canonical report, skip Splunk HEC delivery and never install the Client-Side Cache copy; cached uploads and targeted rechecks reject canonical reports whose metadata.operationMode is not Self Service or Silent
  • Security: runtime state moved off world-writable /var/tmp
    • Canonical report and lock, SOFA and networkQuality caches now live in root-owned organizationDirectory; cached reports are trusted only when they are root-owned regular files
    • User-facing Inspect assets (config and compliance plist) now live in a root-owned 0755 tree at /Library/Application Support/${reverseDomainNameNotation}/Inspect, with per-user control files under Inspect/Users/<user>; the canonical report, secrets and caches remain root-only in organizationDirectory
    • Root-owned, non-symlink pre-5.0.0 /var/tmp leftovers and 5.0.0-beta Inspect leftovers in organizationDirectory are removed automatically
    • swiftDialog command and JSON files are now root-owned 600 with a read-only ACL for the console user (previously world-readable 644)
    • The /var/tmp/MacHealthCheck-Force-Fresh-Run trigger is honored only when root-owned; triggers created by other users are ignored, logged and removed
  • Security: hardened code based on Monocle findings
    • Removed /usr/local/bin from the script and LaunchDaemon PATH; swiftDialog now runs from Dialog.app/Contents/MacOS/dialogcli, Jamf Pro from /usr/local/jamf/bin/jamf, and jq from /usr/bin/jq or a root-owned install only (user-owned Homebrew jq is rejected)
    • Refactored Resources/createSelfExtracting.zsh (now #!/bin/zsh --no-rcs) so generated wrappers decode into a root-only mktemp -d directory, run /bin/zsh --no-rcs with all forwarded arguments (Jamf Pro Parameters 1-11) and remove the copy on exit, replacing the fixed, pre-plantable /var/tmp/MHC.zsh path; removed the --target option
    • Resources/Makefile now installs the package payload to root-owned /Library/Management/org.churchofjesuschrist/Mac-Health-Check.zsh instead of user-writable /usr/local/bin/Mac-Health-Check, stages packages under the per-user $TMPDIR and refuses a staging directory it does not own; Resources/postInstall.zsh runs the payload with /bin/zsh --no-rcs in Self Service mode
    • Pinned Semgrep to 1.177.0 in .github/workflows/security-scan.yml
  • Client-Side Cache
    • installClientSideScript() copies the running script into the root LaunchDaemon's path only when it is a root-owned file whose parent directories are root-owned and not group- or world-writable (new isTrustedRootPath()), and refuses to install a sanitized copy that fails zsh -n or lacks the Silent default
    • isTrustedRootPath() reads the sticky bit (stat -f %Mp%Lp; %Lp alone drops it), so scripts decoded by self-extracting wrappers into a root-only directory under sticky /var/tmp install the Client-Side Cache copy instead of always logging install skipped
    • Installs or refreshes the client-side copy before the cached-upload shortcut, so content changes reach the nightly LaunchDaemon copy even without a scriptVersion bump
    • The nightly LaunchDaemon run no longer sends Microsoft Teams / Slack webhook messages (it refreshes the cached report only)
    • Cached-upload runs skip the two whole-disk mdfind queries and system_profiler (their values are only logged by full runs)
  • Added targeted Self Service remediation verification for Issue #103: valid non-healthy reports with a full-run baseline under 36 hours now rerun only affected stable check keys, merge results into the canonical full-state report with per-check timestamps, and fall back safely to a full run when validation fails; Jamf Pro targeted rechecks also run Computer Inventory, and targeted webhook messages are sent only when a rechecked status changes
    • Nightly Client-Side Cache Silent reports also serve as targeted-recheck and cached-replay baselines: check-set validation ignores Computer Inventory (which the sanitized client-side copy omits), targeted merges append the rechecked Computer Inventory result, and its absence from the base does not count as a status change for webhook messages
  • Added warning-only Memory Pressure history for full health-check runs, nightly Silent refreshes, and targeted memory-pressure rechecks, with a root-only 14-day JSON Lines history and a two-distinct-day pattern threshold over seven days; cached uploads and replay retain their original observations
  • Added checkClockSkew() to Jamf Pro runs to detect local clock offset against time.apple.com before inventory submission and flag skew above 5 minutes
  • checkAPNs() now also reads apsd courier connections and incoming-message acknowledgements (thanks, Der Flounder!) in a single log show query; the ManagedClient Received HTTP response (200) match remains the MDM success evidence because apsd redacts push topics as <private>
    • APNs activity without an MDM response in the last 24 hours now reports APNs active; no MDM response as a warning (previously Failed)
    • MDM identity error -25304 newer than the last MDM response now reports MDM identity error as a failure
    • Logs the last APNs activity, last MDM response and courier connection-failure count
    • The no-MDM-response warning reads No MDM response in 24 hours on unenrolled Macs (previously No None response …)
    • Added to the curated Development subset
  • Raised the minimum required swiftDialog version to 3.1.1.4997
    • Updated the generated Preset 6 Inspect config to declare window options through swiftDialog 3.1.1.4997's JSON options block (moveable, ontop, windowbuttons: "min"), replacing an ignored top-level moveable key and adding a minimise button to the detached summary; --ontop --moveable launch flags remain for older swiftDialog builds
    • An empty dialogcli --version no longer passes the swiftDialog minimum-version gate or the latest-production-release shortcut
    • The detached Preset 6 Inspect summary now sets DIALOG_DEBUG=1 only in Debug mode
    • Documented that the Dock-named swiftDialog copy is re-signed ad hoc (Team ID dropped); set enableDockIntegration="false" where PPPC or notification profiles key on swiftDialog's Team ID
  • macOS 27 compatibility
    • Refactored checkAirPlayReceiver() to recognize macOS 27's new missing-key response and enabled-by-default behavior, preventing Status Unknown results when AirPlay Receiver preferences are absent
    • Fixed Battery Cycle Count reporting = on macOS 27, where ioreg prefixes the CycleCount line with a tree marker
    • Suppressed mdmclient AvailableOSUpdates stderr, which macOS 27 rejects as an unrecognized command, so Silent production logs no longer capture the mdmclient usage banner
  • checkJamfProCheckIn() now counts startup, login and networkStateChange triggers alongside recurring check-in, preventing false warnings on Macs powered off overnight, and parses jamf.log timestamps with the current year (falling back to the prior year for future dates), preventing false successes across the December-to-January rollover
  • Microsoft OneDrive Sync Date now uses the local date instead of UTC, preventing evening runs from reporting tomorrow's date (affects the quit summary, help message, Inspect and the Splunk oneDriveSyncDate value)
  • SOFA cache refreshes now revalidate a stale feed with its stored ETag (HTTP 304 refreshes the cache age) instead of deleting the cache first, keep the stale feed when a download fails, and allow 10 seconds (previously 3) for the initial download
  • killProcess() now matches exact process names (pgrep -x) and terminates every matching PID (previously two or more PIDs caused illegal pid and nothing was killed)
  • Cleanup removes only this run's swiftDialog command and JSON files, removes the Dock-named swiftDialog copy only when this run created it, and leaves /var/tmp/dialog.log in place for Silent, so a Silent run no longer deletes a concurrent Self Service run's files
  • ipconfig setverbose is enabled only when it was off and restored only when this run changed it
  • Fixed dark-mode detection for console usernames containing spaces, and replaced the undefined result call in checkOS() with a warning
  • Polished log output; check results, statustext and report values are unchanged
    • checkWiFiStrength() logs Fair results as [WARNING] and Poor results as [ERROR], matching their list-item statuses
    • checkHomebrewStatus() logs its warning-level results as [WARNING] instead of [ERROR]
    • swiftDialog older than swiftDialogMinimumRequiredVersion (when no newer production release exists) logs [WARNING] instead of [PRE-FLIGHT]
    • Leading zero on user-directory disk percentages (i.e., 0.06% of disk)
    • Rounded Network Quality responsiveness and removed the trailing ;
    • Warning: / Error: spacing in checkExternalJamfPro()
    • ; separator between Time Machine destinations and backup dates
    • Removed trailing ; from checkNetworkHosts() and checkElectronCornerMask() logs, and the trailing space from Run "…" as "<UID>" … lines
    • DDM Resolver logs explain non-zero resolver exits and show build=unavailable instead of (null)
    • Inspect Summary Replay logs one specific reason when falling back to a full run (removed the generic no eligible cached summary line)
    • Client-Side Cache logs evaluating (instead of installing) before checking whether the cached copy is current, and logs generated LaunchDaemon plist validated only when an install proceeds
    • Kandji's Microsoft One Drive check logs its list-item title (previously Microsoft OneDrive)
  • Removed unused getEntraPSSOStatusRaw() and getEntraLegacyCertificateOutput() helpers
  • Reports from earlier versions (including 5.0.0 betas) do not match the 5.0.0 script version, so the first 5.0.0 Self Service run on each Mac is a full run
  • Agent Experience
    • Introduced the mac-health-check-selector AI Skill to assist Mac Admins with custom deployment; after the selection is confirmed, it asks whether to remove other MDMs' code from the artifact (default no)
    • build-artifact.zsh --prune-other-mdms removes every other MDM's list-item array, its branches in each vendor case block (including serverURL detection), and unreferenced vendor-only functions (checkJamfProCheckIn, checkJamfProInventory, checkExternalJamfPro, updateComputerInventory, jamfHosts, checkMosyleCheckIn), keeps the generic fallback, and validates the result with checks 4b and 4c
    • build-artifact.zsh refuses a source script that is world-writable or owned by neither the current user nor root, and check 5d confirms the sanitized Client-Side Cache copy keeps the Silent default
    • references/health-checks.md documents the Not Running and timeout behavior and the three-check Development subset
    • AGENTS.md treats scriptVersion as canonical (VERSION.txt is git-ignored) and replaces the health-check template with a real single-argument dialogUpdate pattern
    • .github Copilot agents and instructions rewritten for Mac Health Check (reminder-* files renamed to inspect-*); bug reports add Targeted remediation recheck and Reporting secrets areas
    • AGENTS.md uses branch-neutral release-state wording, adds a selector-skill step to the Add New Health Check skill, lists every selector file to keep synchronized, and uses zsh --no-rcs for test runs; added a tracked one-line CLAUDE.md (@AGENTS.md)
    • Selector skill documents checkAPNs() warning-or-fail outcomes, the external-check parsing order (timeout, defaults domain, keywords), helper --source / --out-dir / -h options, and new [H7] / [M2] sidecar notes
    • .gitignore now ignores built packages (Resources/*.pkg), self-extracting scripts (*_self-extracting-*.sh), .claude/settings.local.json and .codex/ (.codex/config.toml is no longer tracked)
  • Documentation: README, Diagrams/, Resources/, SECURITY.md, CONTRIBUTING.md and issue templates refreshed for 5.0.0 (check counts per MDM, three-check Development subset, Client-Side Cache install order, report fields)
    • README adds Script Parameters (noting that Parameter 4 is case-sensitive and not validated), Exit Codes and MDM Detection sections, documents that webhook messages are effectively Jamf Pro-only, describes checkAPNs() outcomes, notes that the four Jamf Pro sample external checks run by default, warns that the uninstall snippet also deletes MacHealthCheck-Secrets.plist, and regenerates the Policy Log Reporting sample from the current log line
    • SECURITY.md adds 5.0.0 Security Notes (secrets file, Parameter 5 / 8 rejection, post-beta token rotation, transport and root-owned state); CONTRIBUTING.md adds a pre-submit checklist
    • Resources/README.md documents what the package's post-install run does and that self-extracting runs install the Client-Side Cache; Splunk-Dashboard-Reference.md notes gatekeeper__xprotect and _time dedup caveats; external-checks/README.md shows the runConfiguredHealthCheck call form and how Error / Timed Out / Not Installed are recorded
    • Diagrams/ corrected for Silent swiftDialog gating, exit codes and paths, fail-versus-warning results, Jamf Pro-only webhooks, Mermaid node placement and missing organization defaults